Master Checklists

linux Linux Master Checklist

Full Debian/Ubuntu + RHEL/Fedora hardening checklist — accounts, SSH, firewall, malware hunting, and more.

0 / 0 checked

Merged from: In-Depth Linux Checklist, Linux Mint Checklist, Linux Script, and Ultimate Linux Checklist. Covers Debian/Ubuntu/Mint (apt/dpkg) and RedHat/Fedora/CentOS (yum/dnf) families. Package-manager differences are called out explicitly wherever they matter.

CIS Benchmark grounding: Many "Things to try / extra points" tips below are cross-referenced against the CIS Ubuntu Linux Benchmark / CIS Debian Linux Benchmark (apt family) and the CIS CentOS Linux Benchmark / CIS Red Hat Enterprise Linux Benchmark (yum/dnf family), published by the Center for Internet Security. Where a tip aligns with a CIS control area, it's called out by category name (e.g., "Filesystem Configuration," "Access, Authentication and Authorization"). Exact control ID numbers are intentionally omitted where not certain, since numbering shifts between benchmark versions/releases — use the category names to locate the matching control in whichever benchmark PDF your team has on hand. Controls are also flagged Level 1 (baseline hardening, low functionality risk) vs. Level 2 (stricter, higher chance of breaking something) per CIS's own profile split — treat Level 2 items as stretch goals, not requirements.

Golden rules:

  1. Read the README on the Desktop FIRST. It defines the scenario, required users, required services, and forbidden items. Nothing in this document overrides it.
  2. Answer forensics questions BEFORE hardening. Many hardening steps (rotating logs, restarting services, deleting files, locking accounts) destroy the evidence you need to answer them.
  3. Don't break required functionality. Deleting a required user/service/package costs more than leaving a minor vuln unfixed. When unsure, disable/lock rather than delete.
  4. Snapshot before you change things. Keep a running text file of every command you run so you can backtrack if something breaks.
  5. Reboot networking/SSH changes carefully. Test config syntax (sshd -t) before restarting the SSH service — don't lock yourself out.

Table of Contents#

1. Initial Recon & README#

  • Open the Desktop README (or scenario document) and read it fully before touching anything.
  • Note the required users, required groups, required services, and any explicitly authorized software.
  • Identify the distro family and package manager in use:
bash
cat /etc/os-release
lsb_release -a 2>/dev/null
uname -a
Expected result:
  • /etc/os-release prints NAME=, VERSION=, and ID= lines (e.g. ID=ubuntu or ID=debian or ID=centos) — this is the most reliable source.
  • lsb_release -a prints Distributor ID:, Description:, Release:, Codename:.
  • uname -a prints one line with kernel name, hostname, kernel version, and architecture (e.g. x86_64).
If it fails:
  • lsb_release: command not found is common on minimal Debian/CentOS/Fedora installs because the lsb-release (Debian/Ubuntu) or redhat-lsb-core (RHEL/CentOS) package isn't installed by default — skip it and rely on /etc/os-release, which exists on virtually every modern distro, or check /etc/redhat-release / /etc/debian_version directly.
  • If /etc/os-release itself is missing (very old or stripped-down image), fall back to cat /etc/*-release or uname -a alone to at least identify the kernel.
  • Identify which package manager applies to this image:
Distro Family Package Manager Update Command
Debian / Ubuntu / Mint apt / dpkg sudo apt update && sudo apt upgrade -y
RHEL / CentOS 7 yum sudo yum update -y
Fedora / CentOS 8+ / RHEL 8+ dnf sudo dnf update -y
  • Take an inventory snapshot of the system before changes (users, services, packages, listening ports) so you can compare later and answer forensics questions.

Things to try / extra points#

bash
# Quick system fingerprint
hostnamectl
cat /etc/issue
who -a
last -a | head -20        # recent logins — spot suspicious login history early

# CIS Benchmark — Filesystem Configuration: check partition layout
# CIS calls for separate partitions for /tmp, /var, /var/log, /var/log/audit, and /home
# where feasible — competition images rarely have this, so it's usually informational only,
# but worth knowing before you start remounting things in Section 18.
mount | column -t
lsblk
Expected result:
  • hostnamectl prints hostname, machine ID, OS, kernel, and architecture in a labeled block.
  • who -a/last -a print login session tables (username, tty, time, and for last, duration/still-logged-in status).
  • mount/lsblk print current mount points and the block-device/partition tree.
If it fails:
  • hostnamectl: command not found means systemd isn't in use (rare, but possible on a minimal/older or non-systemd distro) — fall back to plain hostname + uname -a.
  • last -a showing "wtmp begins ..." with very little history just means the log file was recently rotated/cleared — not an error, but worth noting as a possible forensics clue (logs shouldn't normally be empty).
  • If lsblk isn't installed (very stripped-down image), fdisk -l (as root) or cat /proc/partitions are near-equivalents.
bash
df -h

# Fallback if lsb_release isn't installed (common on minimal RHEL/CentOS images) — /etc/os-release
# is present on virtually every modern distro and is the more portable source of truth anyway
cat /etc/*-release 2>/dev/null

# Edge case: confirm the system clock/timezone is correct BEFORE you start relying on log timestamps
# for forensics answers or an audit trail — a wrong clock makes "when did X happen" unanswerable
timedatectl 2>/dev/null || date

# Verification: sanity-check hostname and network identity match what the README describes
# (a swapped hostname is sometimes itself part of a scenario's forensics questions)
hostname; hostname -I 2>/dev/null
Expected result:
  • df -h prints a table of mounted filesystems with human-readable sizes/usage percentages.
  • cat /etc/*-release prints one or more distro-identifying files.
  • timedatectl prints local time, timezone, and NTP sync status; date (its fallback) just prints the current date/time on one line.
  • hostname/hostname -I print the machine's name and its IP address(es).
If it fails:
  • timedatectl missing (command not found) confirms non-systemd or a very old distro — date alone is the reliable fallback and is always present.
  • hostname -I printing nothing usually means no interface has an IP yet (networking not up) — check with ip a instead to see interface state directly.
  • If the system clock looks wrong, fix it (sudo timedatectl set-time "YYYY-MM-DD HH:MM:SS" or sudo date -s "...") before doing any log-timestamp-based forensics, since a wrong clock invalidates timestamp comparisons.

GUI alternative (Mint/Ubuntu): System Info / About This Computer (Settings → About) shows OS version, kernel, and hardware at a glance — faster than typing commands if you just need a quick read, though it won't give you the package-manager-specific detail os-release does. Save a before.txt snapshot of dpkg -l / rpm -qa, ss -tulpn, and /etc/passwd right at the start — invaluable for diffing later and for forensics answers. If the image already has separate partitions for /tmp, /var, or /home, that's a CIS Filesystem Configuration win already in place — note it so you know which nodev/nosuid/noexec mount options apply where in Section 18.

SNAPSHOT CHECKPOINT — take one right now, before you touch anything. Take a snapshot/checkpoint of the virtual machine itself in your hypervisor (VirtualBox: Machine → Take Snapshot; VMware: VM → Snapshot → Take Snapshot; Hyper-V: Checkpoint). This is a host-level rollback point, completely separate from anything you do inside the guest OS — if a later step breaks the system beyond repair, you revert to this snapshot and only lose time, not your score. Label it something like pre-hardening so it's easy to find later.


2. Forensics Questions Strategy#

CyberPatriot images typically include a set of forensics questions (text files on the Desktop) worth points independent of the scoring engine.

  • Answer forensics questions before you start locking accounts, clearing logs, or restarting services — some answers live in transient state (current logins, running processes, recent log entries).
  • Common forensics question categories: "how many users have X property," "find the hidden file containing Y," "what is the MD5 hash of file Z," "which user last logged in on date," "find the malicious script and report its purpose."
  • Use find, grep, md5sum/sha256sum, stat, and file to answer without modifying evidence.

Things to try / extra points#

bash
# Hash a specific file for a forensics answer
md5sum /path/to/file
sha256sum /path/to/file

# Find files modified/accessed in a time window (useful for "when was X placed")
sudo find / -xdev -newermt "2026-08-01" -type f 2>/dev/null

# Metadata on a file (owner, timestamps, permissions) without altering it
stat /path/to/file

# Identify file type regardless of extension (common forensics trick: renamed files)
file /path/to/suspicious_file

# Extract readable text from a binary or otherwise unreadable file — useful when a forensics
# question asks you to find a flag/string buried inside a non-text file
strings /path/to/file | less

# Build a quick timeline of recently changed files across the whole system, sorted oldest to newest —
# faster than repeated `find -newermt` guesses when you don't know the exact time window
sudo find / -xdev -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -50

# Edge case: an attacker-renamed file often keeps its original extension pattern in its magic bytes
# even if the filename was changed — `file` output plus `strings` together usually beats guessing
# from the filename alone.
Expected result:
  • md5sum/sha256sum print a hex hash followed by the filename.
  • find -newermt prints matching file paths (empty output = nothing matched that window, not necessarily an error).
  • stat prints a labeled block (Access/Modify/Change times, UID/GID, permissions).
  • file prints a one-line type guess (e.g. ELF 64-bit LSB executable or ASCII text) regardless of the file's extension.
  • strings streams readable text fragments through less for paging.
If it fails:
  • "Permission denied" scattered throughout find/stat output when NOT run as root/sudo on protected paths — expected noise, not a real problem, since the command still completes; add sudo if you specifically need to search root-owned areas like /root.
  • strings with no useful output on a text file just means there's nothing binary to extract — open it directly with cat/less instead.
  • If file reports "cannot open" for a path you're sure exists, double-check for a typo or a broken symlink (ls -la on the path will show -> target and whether the target exists).

Verification: after answering a forensics question, re-run the same find/grep you used to double check the answer is still consistent — some teams lose points by answering from a stale first look and then changing something in a later section that would have altered the answer.

Copy suspicious files to a scratch folder before analyzing so you don't accidentally alter timestamps or content on the original.

Encoded / Obfuscated Data#

A recurring forensics pattern — a real CyberPatriot practice round included a base64-encoded message on the desktop — is a string or file that isn't plain text because it's encoded. Recognize the pattern, then decode:

Looks like Likely encoding Tell
SGVsbG8gV29ybGQh Base64 Only letters/digits/+//, often padded with =/==, length a multiple of 4
48656c6c6f20576f726c6421 Hex Only 0-9 and a-f, always an even number of characters
01001000 01100101 Binary Only 0s and 1s, usually in 8-character groups
Uryyb Jbeyq ROT13 / Caesar shift Garbled but word lengths/spacing/punctuation match real text
Hello%20World%21 URL encoding % followed by two hex digits
bash
# Base64 decode / encode
echo "SGVsbG8gV29ybGQh" | base64 -d
echo "plain text here" | base64

# Hex decode
echo "48656c6c6f20576f726c6421" | xxd -r -p
# or, if xxd isn't installed:
echo "48656c6c6f20576f726c6421" | python3 -c "import sys; print(bytes.fromhex(sys.stdin.read().strip()).decode())"

# ROT13 decode (self-inverse — same command re-encodes)
echo "Uryyb Jbeyq" | tr 'A-Za-z' 'N-ZA-Mn-za-m'

# URL decode
python3 -c "import urllib.parse,sys; print(urllib.parse.unquote(sys.argv[1]))" "Hello%20World%21"

# Binary decode (space-separated 8-bit groups)
echo "01001000 01100101 01101100 01101100 01101111" | python3 -c "import sys; print(''.join(chr(int(b,2)) for b in sys.stdin.read().split()))"
Expected result:Each line prints the decoded/encoded plain text directly to the terminal.
If it fails:
  • base64: invalid input means the string isn't valid base64 — strip stray whitespace/newlines first (tr -d '[:space:]' before piping in) or double-check you copied the whole string.
  • xxd: command not found — it's part of the vim-common (Debian/Ubuntu) or vim-enhanced/xxd (RHEL/Fedora) package; use the python3 hex-decode fallback shown right below it instead, which needs nothing but a stock Python 3 install.
  • If tr output for ROT13 looks unchanged, double-check you're piping the actual ciphertext in, not accidentally echoing the literal command.
  • Where to look: desktop text files with odd names, .bash_history entries, cron job comments, a file's extended attributes (getfattr -d file), and image metadata (exiftool file.jpg if installed, or file file.jpg at minimum for basic type/dimension info).
  • Steganography (data hidden inside an image) shows up occasionally — a loose tell is an image file unusually large for what it visually shows. steghide info file.jpg or zsteg file.png if either happens to be installed; don't count on them being available on an offline image, and don't sink much time here unless a question explicitly points at a specific file.
  • Try base64 first if unsure which encoding you're looking at — it's by far the most common, and a garbled/failed decode is instant feedback to try something else.

3. User & Group Auditing#

  • Compare the full user list against the README's authorized-user list.
  • List all users with UID and shell:
bash
cat /etc/passwd | awk -F: '{print $1, $3, $7}'
What this awk command does
-F: tells awk to split each line on the colon character (that's how /etc/passwd separates fields: username:password:UID:GID:comment:home:shell). $1, $3, $7 just mean "print field 1, field 3, field 7" — i.e. username, user ID number, and login shell, one line per account.
  • Flag any non-root account with UID 0 (root-equivalent — instant red flag):
bash
awk -F: '($3 == 0) {print}' /etc/passwd
What this awk command does
Same colon-splitting trick as above, but this time it's a filter: ($3 == 0) means "only print lines where field 3 (the UID) equals 0." UID 0 = root-level privileges, so this finds every account that has full root power — there should normally only be one (root itself).
  • Identify normal human accounts (typically UID 1000–60000, distro-dependent) to check against the authorized list:
bash
awk -F: '$3 > 999 && $3 < 65534 {print $1}' /etc/passwd
  • Check for accounts that shouldn't have a login shell (service accounts) but do:
bash
grep -vE '/false|/nologin' /etc/passwd

Expected result (all four /etc/passwd queries above): each prints matching lines/fields from /etc/passwd — the first is every user with UID and shell, the second should print nothing (any output here is a real finding: an unauthorized UID-0 account), the third lists ordinary human accounts to check against the README, the fourth lists everyone with an interactive shell (flag any service/system account that shouldn't have one). If it fails: Empty output from the first command means /etc/passwd itself is missing or unreadable, which would be catastrophic (system won't boot) — extremely unlikely to actually happen; more likely you mistyped the awk field separator. The UID-1000-60000 range in the third command is a convention, not a hard rule — some distros start human UIDs at 500 (older RHEL) or 1001; if the list looks wrong (missing/extra accounts), check /etc/login.defs for UID_MIN/UID_MAX on this specific image and adjust the range.

  • Audit group membership, especially sudo (Debian/Ubuntu) or wheel (RHEL/Fedora):
bash
getent group sudo
getent group wheel
cat /etc/group
  • Audit /etc/sudoers and /etc/sudoers.d/ for unauthorized privilege grants:
bash
sudo cat /etc/sudoers
sudo ls -la /etc/sudoers.d/
sudo visudo -c        # validate syntax before saving any edits
Expected result:
  • getent group sudo/wheel prints a line like sudo:x:27:alice,bob (empty membership after the last : means nobody's in it — normal on some images).
  • visudo -c prints /etc/sudoers: parsed OK on success.
If it fails:
  • getent group wheel returning nothing on a Debian/Ubuntu box is expected — that distro family uses sudo, not wheel; check whichever group name matches the actual distro family from Section 1. "sudo: a password is required" on sudo cat /etc/sudoers means your own account isn't in the sudo/wheel group yet — you may need to work from a root shell or an account the README confirms is an authorized admin.
  • Never save a /etc/sudoers edit without running visudo -c (or editing through visudo itself in the first place) — a syntax error in this file can lock out sudo for everyone, including you, until it's fixed via single-user/root recovery mode.
  • Remove unauthorized users from privileged groups (don't delete the account outright unless told to — see below):
bash
sudo deluser <username> sudo         # Debian/Ubuntu
sudo gpasswd -d <username> wheel     # RHEL/Fedora
  • For truly unauthorized accounts, prefer locking over deleting (deleting a required account you misjudged costs points; locking is reversible):
bash
sudo passwd -l <username>
sudo usermod -L -s /usr/sbin/nologin <username>
  • Only fully remove an account if the scenario explicitly says to eliminate it:
bash
sudo userdel -r <username>   # -r also removes home dir — use with caution
Expected result:
  • deluser/gpasswd -d print a one-line confirmation (e.g. Removing user 'x' from group 'sudo'...).
  • passwd -l prints passwd: password expiry information changed. and prefixes the shadow-file hash with ! (locked).
  • usermod -L is silent on success.
  • userdel -r is silent on success and removes the home directory.
If it fails:
  • "deluser: The user does not belong to group sudo" just means they weren't in it to begin with — not an error, nothing to fix. "userdel: user X is currently logged in" — you can't delete an account with an active session; either wait for them to log off, or (if this is genuinely necessary) kill their session first with sudo pkill -KILL -u <username> before retrying.
  • Locking, not deleting, is always the safer default when you're not 100% sure — passwd -l/usermod -L are trivially reversible (passwd -u / usermod -U), userdel -r is not.
  • Set/repair passwords for all authorized users to meet policy; never leave default/blank passwords.
  • Lock the root account from direct login (still allow sudo where required):
bash
sudo passwd -l root
Expected result:Same as the passwd -l behavior above — root's shadow entry gets prefixed with !, direct root login (console/SSH password auth) is blocked, but sudo still works fine for permitted users since it doesn't go through root's own password.
If it fails:If you get locked out of root entirely and also need direct root access for some reason, unlock with sudo passwd -u root from any account that still has sudo rights — this is exactly why you verify your own sudo access works before locking root.
  • Disable guest account (Mint/Ubuntu with LightDM):
bash
echo "allow-guest=false" | sudo tee -a /etc/lightdm/lightdm.conf
Expected result:The line is appended to the file (confirm with cat /etc/lightdm/lightdm.conf); the guest session option disappears from the LightDM login screen after a reboot or sudo systemctl restart lightdm.
If it fails:
  • /etc/lightdm/lightdm.conf: No such file or directory means this system isn't using LightDM (common if it's a server image with no GUI, or uses GDM/SDDM instead) — this step doesn't apply; skip it.
  • If the setting doesn't take effect after restarting LightDM, check whether a duplicate/conflicting allow-guest= line already exists earlier in the file or in /etc/lightdm/lightdm.conf.d/*.conf — LightDM reads all of them and a later file can override an earlier one.
  • Disable automatic login for any account (except possibly your own during setup).

Things to try / extra points#

bash
# Find accounts with empty password fields ("::") in /etc/shadow — critical
sudo awk -F: '($2 == "" ) {print $1}' /etc/shadow

# Find accounts with locked (!) or disabled (*) passwords — verify these are intentional
sudo awk -F: '($2 == "!" || $2 == "*") {print $1}' /etc/shadow

# Alternate one-liner for empty-password accounts directly on /etc/passwd (older systems)
mawk -F: '$2 == ""' /etc/passwd

# Verify only root owns/can write /etc/passwd and /etc/shadow
ls -l /etc/passwd /etc/shadow
# Expect: -rw-r--r-- root root  (passwd)   and   -rw-r----- root shadow (shadow)

# Check /etc/securetty to control which ttys root may log in from
cat /etc/securetty

# Set home directory permissions for standard users (deny other users from browsing each other's homes)
for i in $(awk -F: '$3 > 999 && $3 < 65534 {print $1}' /etc/passwd); do
  [ -d /home/${i} ] && sudo chmod -R 750 /home/${i}
done

# List every account and last password change date
sudo chage -l <username>

# CIS Benchmark — Access, Authentication and Authorization: ensure no duplicate UIDs, GIDs, usernames, or group names
awk -F: '{print $3}' /etc/passwd | sort | uniq -d          # duplicate UIDs
awk -F: '{print $1}' /etc/passwd | sort | uniq -d          # duplicate usernames
awk -F: '{print $3}' /etc/group  | sort | uniq -d          # duplicate GIDs

# CIS — ensure the "shadow" group is empty (no user should be directly assigned to it)
awk -F: '($1=="shadow") {print $4}' /etc/group

# CIS — ensure root is the only UID 0 account AND ensure root's PATH integrity (no writable/world dirs, no "." in PATH)
sudo -u root env | grep '^PATH='

# CIS — ensure all users' home directories actually exist and are owned by that user
while IFS=: read -r user _ uid gid _ home _; do
  [ "$uid" -ge 1000 ] && [ ! -d "$home" ] && echo "Missing home dir: $user -> $home"
done < /etc/passwd

# CIS — ensure local interactive user home directories are mode 750 or more restrictive (mirrors the chmod loop above)

# Verification: confirm a lock actually took effect (look for "L" or "!" in the password-status field)
sudo passwd -S <username>
# P = usable password, L = locked, NP = no password set

# Verification: confirm a removed sudo/wheel membership actually took — re-run the group check
# after the change instead of assuming the deluser/gpasswd command succeeded
getent group sudo; getent group wheel

# Edge case: environments joined to LDAP/NIS/AD won't show all accounts in /etc/passwd — if the
# README mentions a directory service, check the broader account source too, not just local files
getent passwd | wc -l          # compare this count against `wc -l /etc/passwd` — a big gap means
                                # accounts are coming from somewhere other than local files
cat /etc/nsswitch.conf | grep ^passwd

# Edge case: an attacker-added account sometimes hides in the SYSTEM UID range (1-999) rather than
# the normal human range, banking on you only checking UID 1000+ — skim the low range too, especially
# any entry with a real login shell instead of nologin/false
awk -F: '$3 < 1000 && $7 !~ /nologin|false/ {print}' /etc/passwd
What this chmod command does
-R applies the change recursively (the folder and everything inside it, not just the top-level folder). 750 breaks down as three digits = owner/group/others: 7 (owner: read+write+execute), 5 (group: read+execute), 0 (others: nothing) — so only the account owner can fully use their home folder, others on the box get zero access to it.
Expected result:
  • Each read-only check prints matching accounts/lines, or nothing if there's no finding (empty output = clean on the empty-password, duplicate-UID, and shadow-group checks — that's the good outcome, not a failure).
  • ls -l /etc/passwd /etc/shadow should show exactly -rw-r--r-- (or -rw-r--r--. with SELinux context) for passwd and -rw-r----- for shadow, both owned by root.
  • chage -l prints a labeled block of password-aging dates for that one user.
  • passwd -S prints <username> <status> ... where status is L, P, or NP as commented in the file.
If it fails:
  • Any non-empty output from the empty-password, duplicate-UID/GID, or non-empty-shadow-group checks is a real finding to fix, not a script error.
  • If /etc/shadow permissions show anything more permissive than 640 root:shadow (e.g. world-readable), that's itself a serious, commonly-scored vulnerability — fix with sudo chmod 640 /etc/shadow; sudo chown root:shadow /etc/shadow.
  • The home-directory ownership loop printing nothing means everything's fine; if it lists a mismatch, fix with sudo chown -R <user>:<user> <homedir>.
  • getent passwd | wc -l being much larger than wc -l /etc/passwd confirms accounts are coming from LDAP/NIS/AD, not just local files — if the README describes a domain/directory-service scenario, you must audit that directory's accounts too, not just local ones (which is generally outside plain /etc/passwd editing and needs the relevant directory-service tooling instead).

GUI alternative (Mint/Ubuntu): Users and Groups app (Settings → Users, or users-admin) lets you view accounts, lock/unlock, and change group membership with clicks — a reasonable way for a teammate to double-check your terminal work visually, though it won't show the low-UID system accounts as clearly as the command above. This section aligns closely with CIS Benchmark guidance under Access, Authentication and Authorization — the empty-password check, non-root UID 0 check, and sudo/wheel group audit above are all explicit CIS Level 1 controls, not just competition heuristics.

CIS Level 2 to try if time allows: consider setting nologin as the shell for all system/service accounts that don't need one (CIS "ensure system accounts are secured"), and confirming /etc/shells doesn't list nologin as a valid shell (a subtle CIS check that trips people up). Don't apply this to any account your README lists as needing interactive login.

usermod -L locks the password but leaves the account and files intact — always safer during a competition than userdel, which can accidentally erase required home directories and tank your score.


4. Password Policy (PAM, login.defs)#

  • Set password aging policy in /etc/login.defs. This file only sets defaults for newly created accounts — four separate settings, applied one at a time so you can confirm each took effect:
  1. Maximum password age — forces a password change at least this often:
bash
sudo sed -i 's/^PASS_MAX_DAYS.*/PASS_MAX_DAYS   90/' /etc/login.defs
What this sed command does
sed -i edits the file in place. 's/PATTERN/REPLACEMENT/' means "find text matching PATTERN and swap in REPLACEMENT." Here it's finding the PASS_MAX_DAYS line in /etc/login.defs (however it currently reads) and rewriting the whole line to set the value to 90. The ^ means "start of line" and .* means "anything after that" — so it replaces the entire line, not just the number.
  1. Minimum password age — stops a user from immediately changing back to their old password after being forced to rotate:
bash
sudo sed -i 's/^PASS_MIN_DAYS.*/PASS_MIN_DAYS   10/' /etc/login.defs
  1. Minimum password length — rejects short passwords at creation time (belt-and-suspenders alongside pam_pwquality below):
bash
sudo sed -i 's/^PASS_MIN_LEN.*/PASS_MIN_LEN    8/' /etc/login.defs
  1. Warning age — gives users a heads-up before their password expires, so it doesn't lock them out mid-round:
bash
sudo sed -i 's/^PASS_WARN_AGE.*/PASS_WARN_AGE   7/' /etc/login.defs
  1. Verify all four landed:
bash
grep -E "^PASS_(MAX_DAYS|MIN_DAYS|MIN_LEN|WARN_AGE)" /etc/login.defs

Expected result (all sed edits + the verify grep): each sed -i runs silently (no output = success); the final grep prints all four lines back with your new values (e.g. PASS_MAX_DAYS 90). If it fails: If the grep still shows the OLD value, the sed pattern didn't match — the line in this file may have different spacing/tabs than the pattern expects, or the key might be commented out (#PASS_MAX_DAYS) rather than active; open /etc/login.defs in a text editor and check/edit the line directly if sed isn't landing. If the key is genuinely missing from the file, just append it: echo "PASS_MAX_DAYS 90" | sudo tee -a /etc/login.defs.

Apply the new PASS_MAX_DAYS/PASS_MIN_DAYS to existing users too — login.defs only affects newly created accounts, so without this step every account that already existed on the image keeps its old (often "never expires") aging settings:

bash
sudo chage --maxdays 90 --mindays 10 --warndays 7 <username>

To apply that to every real human account at once instead of one at a time:

bash
for u in $(awk -F: '$3 > 999 && $3 < 65534 {print $1}' /etc/passwd); do sudo chage --maxdays 90 --mindays 10 --warndays 7 "$u"; done
Expected result:
  • Both commands run silently on success.
  • Confirm with sudo chage -l <username> afterward — it should show your new max/min/warn values instead of the defaults.
If it fails:
  • chage: user '<username>' does not exist is a typo — recheck the exact name with cut -d: -f1 /etc/passwd.
  • The loop version silently skips any account whose chage call fails (e.g. a system account with a locked/invalid shell) — that's fine, it's only meant to touch real human accounts; if a real user seems to have been skipped, re-run chage on them individually and read the actual error.
  • Install and configure password complexity enforcement:
Distro Package Module
Debian/Ubuntu (older) libpam-cracklib pam_cracklib.so
Debian/Ubuntu (newer)/Mint libpam-pwquality pam_pwquality.so
RHEL/Fedora/CentOS pam_pwquality (usually pre-installed) pam_pwquality.so
bash
sudo apt install libpam-pwquality -y        # Debian/Ubuntu/Mint
sudo dnf install pam_pwquality -y           # Fedora/RHEL 8+
sudo yum install pam_pwquality -y           # CentOS 7
Expected result:Normal apt/dnf/yum install output ending in something like Setting up libpam-pwquality ... / Complete!.
If it fails:
  • "Unable to locate package" / "No match for argument" means either you're on the wrong package manager for this distro (double check Section 1's distro identification) or there's no network access — CyberPatriot images sometimes have internet disabled once scoring starts; if so, check whether the package is already installed first (dpkg -l | grep pwquality or rpm -q pam_pwquality) before assuming you need to fetch it — many images ship it pre-installed since it's such a common requirement.
  • If truly offline and not preinstalled, this step may not be achievable — note it and move on rather than losing time on it.
  • Edit /etc/pam.d/common-password (Debian family) or /etc/pam.d/system-auth and /etc/pam.d/password-auth (RHEL family) to enforce complexity:
shell
password requisite pam_pwquality.so retry=3 minlen=14 ucredit=-1 lcredit=-1 dcredit=-1 ocredit=-1 enforce_for_root maxrepeat=3 maxsequence=3 dictcheck=1
Expected result:
  • No output when you save the file — this is a config line, not a command.
  • Verify enforcement with the throwaway-test-account trick further down this section (try setting a weak password on a test user and confirm it's rejected).
If it fails:
  • If weak passwords are still accepted after editing, the most common cause is editing the wrong file for this distro family (Debian uses common-password, RHEL/Fedora use system-auth/password-auth — editing the wrong one silently does nothing) or a duplicate/conflicting password line elsewhere earlier in the same file that PAM evaluates first and short-circuits on.
  • Also confirm the line uses requisite (stops immediately on failure) not optional — a typo'd control field silently defeats the whole rule.
  • If RHEL/Fedora uses authselect (see the fallback check further down), your hand-edit can get silently overwritten the next time authselect regenerates its profile — check authselect current first.
  • maxrepeat=3 — CIS: blocks passwords with too many of the same character repeated consecutively (e.g. aaaa1234).
  • maxsequence=3 — CIS: blocks simple sequential runs (e.g. abcd1234, 1234abcd).
  • dictcheck=1 — CIS: rejects passwords found in a dictionary word list (needs cracklib-runtime/cracklib-dicts installed, usually a dependency of the pwquality package already).
  • Enforce password history (prevent reuse) via pam_unix.so:
shell
password [success=1 default=ignore] pam_unix.so obscure use_authtok try_first_pass sha512 remember=5
Expected result:
  • No output on save.
  • Test by changing a test account's password 6 times in a row with different values each time — the 6th attempt reusing one of the last 5 should be rejected.
If it fails:
  • History enforcement not working usually means pam_pwquality's line above it isn't set to requisite correctly, or this line's position relative to the pam_pwquality line in the file is wrong — pam_unix.so needs to come AFTER the quality check line, not before, since PAM processes password-stack lines top to bottom.
  • Also check /etc/security/opasswd exists and is writable (root-only) — that's where pam_unix stores password history hashes to compare future changes against; if it doesn't exist, sudo touch /etc/security/opasswd && sudo chmod 600 /etc/security/opasswd creates it.
  • Enforce account lockout after failed attempts. Older systems use pam_tally2; modern systems use pam_faillock.
bash
# Older (Debian/Ubuntu with pam_tally2 available)
echo 'auth required pam_tally2.so deny=5 onerr=fail unlock_time=1800' | sudo tee -a /etc/pam.d/common-auth

# Modern (pam_faillock — Ubuntu 20.04+/Debian 11+/RHEL 8+)
# Add near the top of /etc/pam.d/common-auth (Debian) or /etc/pam.d/system-auth (RHEL):
#   auth required pam_faillock.so preauth silent deny=5 unlock_time=1800
#   auth [default=die] pam_faillock.so authfail deny=5 unlock_time=1800
What this PAM line does
This adds a rule to the login authentication chain: deny=5 locks the account after 5 failed password attempts, unlock_time=1800 automatically unlocks it after 1800 seconds (30 minutes), and onerr=fail means if pam_tally2 itself hits an internal error, treat that as a failure too (fail closed, not open) — a safer default for a security control.
Expected result:
  • The tee -a line prints the appended line back to the terminal (that's tee's normal echo behavior) and appends it to the file.
  • Test with 5 deliberate wrong-password attempts on a throwaway account, then confirm it's locked (see the faillock/pam_tally2 check commands right below in "Things to try").
If it fails:
  • "pam_tally2.so: No such file or directory" (as an auth error, not a shell error) means that module isn't installed on this system — it was dropped from newer PAM packages; use the pam_faillock block instead, which is the actively-maintained replacement on any reasonably modern distro.
  • Test lockout behavior in a second, already-authenticated terminal/session before you risk locking yourself out — if you get the deny=/unlock_time= line placement wrong in the PAM stack, it's possible to lock out logins entirely, including yours; keep your current session open until you've confirmed a normal, correct login still works.
  • Validate PAM file syntax didn't break login — test in a second terminal/session before closing your current one.

Things to try / extra points#

bash
# View current failed-login lockout state for a user
sudo faillock --user <username>
sudo pam_tally2 --user <username>          # older systems

# Reset a legitimately locked-out authorized user
sudo faillock --user <username> --reset
sudo pam_tally2 --user <username> --reset  # older systems

# Quick sanity check that pwquality is actually loaded
sudo cat /etc/pam.d/common-password | grep pwquality
sudo cat /etc/security/pwquality.conf

# CIS Benchmark — Access, Authentication and Authorization: ensure default user umask is 027 or more restrictive
grep -r "^UMASK" /etc/login.defs
grep -rn "umask" /etc/profile /etc/bash.bashrc /etc/pam.d/postlogin 2>/dev/null

# CIS Benchmark — Access Control (5.4): ensure idle interactive shell sessions auto-logout via TMOUT
# Without this, someone can walk away from an unlocked, unattended root/admin shell indefinitely.
echo 'TMOUT=900' | sudo tee /etc/profile.d/99-tmout.sh
echo 'readonly TMOUT' | sudo tee -a /etc/profile.d/99-tmout.sh
sudo chmod +x /etc/profile.d/99-tmout.sh

# CIS — ensure inactive password lock is 30 days or less (disables accounts whose password has expired and gone unused)
sudo useradd -D | grep INACTIVE
sudo useradd -D -f 30

# CIS — ensure all users' last password change date is in the past (flags clock-skew / manually-edited shadow entries)
sudo awk -F: '{print $1, $3}' /etc/shadow

# CIS — ensure default group for the root account is GID 0
grep "^root:" /etc/passwd

# Verification: confirm pwquality is actually being enforced, not just installed — try setting an
# obviously weak password for a throwaway/test account and confirm PAM rejects it
sudo useradd -m testuser123 2>/dev/null
sudo passwd testuser123   # try "password" or "12345" — should be REJECTED if pwquality is working
sudo userdel -r testuser123 2>/dev/null   # clean up the test account afterward

# Fallback: on distros where /etc/pam.d/common-password doesn't exist (RHEL/Fedora/CentOS use
# /etc/pam.d/system-auth and /etc/pam.d/password-auth instead), authselect/authconfig manages these
# files — editing them by hand can get silently overwritten if authselect regenerates its profile
sudo authselect current 2>/dev/null   # shows whether authselect is managing these files on this box
Expected result:
  • faillock/pam_tally2 --user print the account's current failed-attempt count and lock state.
  • The pwquality.conf cat and grep print the active complexity settings.
  • The TMOUT lines silently create a new profile script that takes effect on next login.
  • useradd -D | grep INACTIVE prints the current default (often -1, meaning disabled).
  • The test-account block should show passwd rejecting the weak password with a message like "BAD PASSWORD: it is too simplistic/systematic" if pwquality is actually working.
  • authselect current either names the active profile or prints "No existing configuration was found" if authselect isn't in use on this system.
If it fails:
  • If the weak-password test account's password is accepted instead of rejected, complexity enforcement isn't actually active — go back and recheck you edited the right PAM file for this distro family and that authselect (RHEL/Fedora) isn't silently reverting your edit; run sudo authselect current to see if it's managing the file, and if so use sudo authselect enable-feature <feature> or a custom profile instead of hand-editing (hand edits under authselect management get wiped on the next authselect apply-changes).
  • Remember to actually run sudo userdel -r testuser123 afterward — don't leave a stray test account on the scored image.
  • TMOUT not triggering an auto-logout means the profile script didn't get sourced — confirm the file is executable (chmod +x) and that you fully logged out/in (or started a fresh shell) rather than just waiting in the same already-loaded session.

This section's password aging (PASS_MAX_DAYS/PASS_MIN_DAYS/PASS_MIN_LEN), pam_pwquality complexity enforcement, and remember=5 history settings all directly map to CIS Benchmark controls under Access, Authentication and Authorization → Password Policy — this is one of the most heavily CIS-covered areas of the whole checklist.

CIS Level 2 to try if time allows: stricter minlen (CIS baseline Level 1 is often 14, Level 2 profiles sometimes push higher) and enforcing enforce_for_root on pam_pwquality so root itself can't set a weak password — only do this once you're confident you won't need to reset root's password under time pressure later.

Never test PAM changes by logging out of your only session. Keep a root shell or a second SSH session open until you've confirmed sudo and normal login still work — a broken PAM stack can lock you out of the entire box.


5. SSH Hardening#

  • Locate and edit /etc/ssh/sshd_config. Key directives to set:
Directive Recommended Value Why
PermitRootLogin no Root should never log in directly over SSH
PasswordAuthentication no (only if key auth confirmed working) Prevents password brute-forcing
PermitEmptyPasswords no Blocks blank-password accounts from SSH
ChallengeResponseAuthentication / KbdInteractiveAuthentication no Removes alternate auth bypass path
X11Forwarding no Reduces attack surface unless required
Protocol 2 SSHv1 is broken/deprecated (irrelevant on very new OpenSSH which is v2-only)
MaxAuthTries 3-4 Limits brute-force attempts per connection
ClientAliveInterval / ClientAliveCountMax 300 / 0 Kills idle/hung sessions
IgnoreRhosts yes Disables legacy trust-based auth
LoginGraceTime 30 Limits time an unauthenticated connection can hold a slot

Apply these one directive at a time rather than as one blind paste — that way, if sshd -t fails afterward, you know roughly which line to suspect first:

  1. Block direct root login over SSH (the single highest-value line in this whole section):
bash
sudo sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
  1. Disable password-based login — only run this once you've confirmed at least one working SSH key login, or you have local console access as a fallback (see the warning callout below):
bash
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
  1. Block blank-password accounts from logging in over SSH, independent of the setting above:
bash
sudo sed -i 's/^#\?PermitEmptyPasswords.*/PermitEmptyPasswords no/' /etc/ssh/sshd_config
  1. Turn off X11 forwarding — closes off a GUI-app tunneling attack surface most scenarios don't need:
bash
sudo sed -i 's/^#\?X11Forwarding.*/X11Forwarding no/' /etc/ssh/sshd_config
  1. Cap authentication attempts per connection — slows down online brute-force guessing:
bash
sudo sed -i 's/^#\?MaxAuthTries.*/MaxAuthTries 3/' /etc/ssh/sshd_config
  1. Kill idle/hung sessions automatically — two settings that work together (send a keepalive probe every 300s; drop the connection if it doesn't respond even once):
bash
sudo sed -i 's/^#\?ClientAliveInterval.*/ClientAliveInterval 300/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?ClientAliveCountMax.*/ClientAliveCountMax 0/' /etc/ssh/sshd_config
  1. Verify all six directives actually landed before moving on:
bash
grep -E "^(PermitRootLogin|PasswordAuthentication|PermitEmptyPasswords|X11Forwarding|MaxAuthTries|ClientAliveInterval|ClientAliveCountMax)" /etc/ssh/sshd_config
Expected result (steps 1-7):each sed -i is silent on success; the final grep echoes back all seven directives with your new values, uncommented.
If it fails:
  • If a directive still shows commented out (#PermitRootLogin ...) or missing entirely after the sed, the ^#\? pattern didn't match that exact line — some configs have leading whitespace before the #, which this pattern doesn't account for; open the file directly and fix that one line by hand, or append a fresh uncommented line at the end of the file with echo "PermitRootLogin no" | sudo tee -a /etc/ssh/sshd_config (a later duplicate directive wins in sshd_config, so appending is a safe fallback).
  • If you see the directive twice after appending (original commented + your new line), that's fine — sshd only honors the last one.
  • Always test config syntax before restarting the daemon — a typo can drop your only remote access:
bash
sudo sshd -t
Expected result:No output at all on success (silence = valid config).
If it fails:Any printed line is a syntax error naming the offending file and line number (e.g. /etc/ssh/sshd_config line 42: Bad configuration option) — go fix that exact line before restarting the service; do not restart sshd while sshd -t is still reporting an error, since on many distros a config error will fail the restart and leave the OLD daemon running (safe), but on some setups (or if you're stopping+starting rather than restarting) it can leave SSH down entirely with no daemon listening at all.

Snapshot reminder: if you haven't taken a VM snapshot yet, take one before you restart SSH. A bad sshd_config can lock you out of your only remote session — with a snapshot, a bad edit costs you a revert instead of the round.

  • Restart the SSH service (name differs by distro):
bash
sudo systemctl restart sshd    # RHEL/Fedora, most modern Debian/Ubuntu
sudo systemctl restart ssh     # Some Debian/Ubuntu builds name the unit "ssh"
Expected result:
  • Silent on success.
  • Confirm with systemctl status sshd (or ssh) showing active (running), and by opening a new SSH session (don't close your current one first) to confirm login still works.
If it fails:
  • "Unit sshd.service not found" — try the other unit name (ssh vs sshd really does vary by distro/version); systemctl list-units --type=service | grep -i ssh shows the actual unit name on this box if neither guess works.
  • If the service fails to (re)start (systemctl status shows failed), re-run sudo sshd -t — you likely have a config error that only surfaces at actual daemon start, not just the syntax check.
  • Keep your current SSH session open until a new test connection succeeds — that's your safety net if the restart broke something.
  • If SSH is not required by the README, consider disabling it entirely rather than just hardening it:
bash
sudo systemctl disable --now sshd
What --now does here
systemctl disable alone only stops a service from auto-starting on the next boot — the service keeps running right now. Adding --now does both at once: stops the currently-running service AND disables it from starting again on reboot. Without --now you'd have to run a separate `systemctl stop` too.
Expected result:
  • Silent on success (or two short "Removed symlink..." lines).
  • systemctl status sshd afterward shows inactive (dead) and disabled.
If it fails:
  • Only run this if you are certain SSH isn't required — if you're connected to this box over SSH right now, running this command will disconnect you immediately and you'll need local/console access to get back in.
  • Double, triple check the README before disabling your own access method.

Things to try / extra points#

bash
# One-shot audit of the most commonly graded directives
sudo grep -E "PermitRootLogin|PasswordAuthentication|PermitEmptyPasswords|Protocol|X11Forwarding|MaxAuthTries" /etc/ssh/sshd_config

# Restrict SSH source IPs at the firewall layer if the scenario specifies a management subnet
sudo ufw allow from 203.0.113.0/29 to any port 22

# Check for a second/rogue sshd config or an sshd listening on a non-standard port
sudo ss -tulpn | grep ssh
ls -la /etc/ssh/sshd_config.d/ 2>/dev/null

# CIS Benchmark — SSH Server Configuration: permissions on sshd_config itself and host key files
sudo ls -l /etc/ssh/sshd_config
sudo chmod 600 /etc/ssh/sshd_config
sudo ls -l /etc/ssh/ssh_host_*_key /etc/ssh/ssh_host_*_key.pub 2>/dev/null
# private keys should be 600 (root-owned), public keys 644

# CIS — additional SSH directives worth setting alongside the ones above
sudo sed -i 's/^#\?LogLevel.*/LogLevel VERBOSE/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?GSSAPIAuthentication.*/GSSAPIAuthentication no/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?HostbasedAuthentication.*/HostbasedAuthentication no/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?AllowTcpForwarding.*/AllowTcpForwarding no/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?MaxStartups.*/MaxStartups 10:30:60/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?MaxSessions.*/MaxSessions 4/' /etc/ssh/sshd_config

# Verification: don't just grep the config file — ask sshd itself what it will ACTUALLY apply.
# sshd -T dumps the fully-resolved effective config, which catches cases where a later line, an
# Include file, or a Match block silently overrides something you set above.
sudo sshd -T | grep -iE "permitrootlogin|passwordauthentication|permitemptypasswords|x11forwarding"

# Edge case: modern OpenSSH (7.4+) supports drop-in config snippets that are read AFTER the main
# file and can override it — if one exists and you didn't edit it too, your fix may be getting undone
ls -la /etc/ssh/sshd_config.d/ 2>/dev/null
grep -rn "PermitRootLogin\|PasswordAuthentication" /etc/ssh/sshd_config.d/ 2>/dev/null

# Fallback for older sysvinit-based systems without systemctl:
sudo service ssh restart 2>/dev/null || sudo /etc/init.d/ssh restart 2>/dev/null

# CIS — three more sshd_config directives worth setting alongside everything above:
sudo sed -i 's/^#\?DisableForwarding.*/DisableForwarding yes/' /etc/ssh/sshd_config      # one setting that blocks TCP/X11/agent/StreamLocal forwarding all at once
sudo sed -i 's/^#\?PermitUserEnvironment.*/PermitUserEnvironment no/' /etc/ssh/sshd_config  # blocks a user-supplied ~/.ssh/environment from injecting variables like LD_PRELOAD
sudo sed -i 's/^#\?UsePAM.*/UsePAM yes/' /etc/ssh/sshd_config                            # note this one should be YES, not no — it's what makes your PAM password/lockout policy actually apply to SSH logins
sudo sed -i 's/^#\?Banner.*/Banner \/etc\/issue.net/' /etc/ssh/sshd_config
Expected result:
  • The audit grep at the top prints the current values of each named directive for a quick sanity check.
  • ufw allow from ... (if using ufw) confirms with Rule added.
  • ss -tulpn | grep ssh shows the listening port/PID for sshd (default :22 unless changed).
  • The ls/permission commands print file listings; the chmod 600 is silent.
  • sshd -T | grep -iE ... — unlike a plain grep on the file — prints the effective, fully-resolved values sshd will actually use, lowercased directive names, one setting per line.
  • authselect/drop-in checks print either matching config snippets or nothing.
If it fails:
  • sshd -T showing a DIFFERENT value than what's in your edited sshd_config file is the important one to catch — it means something else (a Match block further down the file, or a file under sshd_config.d/ read after the main config) is overriding your edit; check ls -la /etc/ssh/sshd_config.d/ and grep those files for the same directive names.
  • If Banner /etc/issue.net doesn't actually show a banner on connect, create the file if it doesn't exist yet (sudo nano /etc/issue.net) — setting the directive doesn't create the file's contents for you.
  • chmod 600 on sshd_config itself is safe and expected; if sshd then fails to start claiming it can't read its own config, you set the ownership wrong somewhere — it must stay root-owned.

This whole section directly mirrors the CIS Benchmark's SSH Server Configuration control group — PermitRootLogin no, PermitEmptyPasswords no, X11Forwarding no, MaxAuthTries, and ClientAliveInterval/ClientAliveCountMax are all named CIS Level 1 controls, not just this doc's opinion. LogLevel VERBOSE, disabling GSSAPIAuthentication/HostbasedAuthentication, the sshd_config file permission (600), DisableForwarding, PermitUserEnvironment no, UsePAM yes, and a configured Banner are additional CIS Level 1 items commonly missed.

CIS Level 2 to try if time allows: restrict Ciphers, MACs, and KexAlgorithms to CIS-approved modern algorithm lists only (removes weak/legacy crypto) — this is more involved to get right and can break older SSH clients, so only attempt it if you have time to test the connection still works afterward.

Don't blindly disable password auth unless you've confirmed at least one authorized key-based login works, or you have local/console access as a fallback — locking out the only login method is a classic self-inflicted point loss.

Why the OpenSSH package version matters, not just sshd_config: Ubuntu shipped a security update in mid-2026 (USN-8533-1) fixing eight OpenSSH vulnerabilities across 22.04/24.04/26.04, including a use-after-free in the SSH client severe enough to allow remote code execution or credential disclosure via a man-in-the-middle attacker. All the sshd_config hardening in this section is worthless against a bug in the SSH binary itself — sudo apt update && sudo apt install --only-upgrade openssh-server openssh-client (or the Package Updates full-upgrade equivalent) is not optional busywork, it's the fix for exactly this class of issue.


6. Firewall (ufw / iptables / firewalld)#

Distro Default Firewall Tool
Ubuntu / Mint / Debian ufw (front-end for iptables/nftables)
RHEL / CentOS / Fedora firewalld
Any (low-level) iptables / nftables directly

Snapshot reminder: flipping to a default-deny-inbound policy can cut off the exact connection you're working over (SSH, a remote desktop session, etc.) if you get the allow-list wrong. Take a VM snapshot before you enable the firewall if you haven't already, and double-check your allow rule for the port you're connected on is in place before you run enable/reload.

Ubuntu/Mint/Debian — ufw#

bash
sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp          # only if SSH is required
sudo ufw enable
sudo ufw status verbose
Expected result:
  • ufw enable prints Firewall is active and enabled on system startup.
  • ufw status verbose then lists Status: active, the default policies, and each allow rule (e.g. 22/tcp ALLOW IN Anywhere).
If it fails:
  • If you're connected over SSH and forgot the allow 22/tcp line before ufw enable, you will lose your connection the moment it enables — this is the single most common self-inflicted lockout in this whole document; always add your own access rule first, verify it with ufw status (before "enable" even), then enable.
  • If already locked out this way, you need local/console/hypervisor access to fix it (ufw disable or add the missing rule from the console). "ERROR: problem running ufw-init" on enable sometimes happens in nested-VM environments where kernel netfilter modules aren't fully loaded — see the raw-iptables fallback further down this section.

RHEL/Fedora/CentOS — firewalld#

bash
sudo systemctl enable --now firewalld
sudo firewall-cmd --set-default-zone=drop
sudo firewall-cmd --permanent --add-service=ssh   # only if required
sudo firewall-cmd --reload
sudo firewall-cmd --list-all
Expected result:firewall-cmd --list-all prints the active zone's config block including services: ssh (if added) and target: DROP.
If it fails:
  • Same lockout risk as ufw above — always add your --add-service=ssh (or equivalent) rule before --reload, and always use --permanent or the rule vanishes on the next reload/reboot (a rule added without --permanent is only live in the current runtime config, easy to lose track of). "Error: INVALID_SERVICE" means the service name is wrong — firewall-cmd --get-services lists every valid service name this system knows.
  • If --set-default-zone=drop seems too aggressive and breaks something unexpected, public is a gentler default zone that still blocks most unsolicited inbound traffic while being less absolute than drop.
  • Enforce default-deny inbound, allow-list only what the README requires.
  • Only allow outbound by default unless the scenario calls for stricter egress control.
  • Restrict SSH (or any admin port) to a specific source range if given one.

Things to try / extra points#

bash
# Confirm ufw survives reboot
sudo systemctl is-enabled ufw

# Inspect raw iptables rules underneath ufw/firewalld (useful for verifying no rogue rules exist)
sudo iptables -L -n -v
sudo iptables -t nat -L -n -v

# fail2ban complements the firewall by dynamically banning brute-force IPs
sudo apt install fail2ban -y                 # Debian/Ubuntu
sudo dnf install fail2ban -y                 # Fedora/RHEL
sudo systemctl enable --now fail2ban

# Delete a specific ufw rule by number if you over-allowed something
sudo ufw status numbered
sudo ufw delete <number>

# CIS Benchmark — Network: ensure only ONE firewall utility is actively managing rules
# Running ufw and firewalld (or raw iptables rules alongside ufw) simultaneously is itself a
# CIS finding — conflicting rule sets are hard to reason about and can silently disable each other.
dpkg -l | grep -E "ufw|firewalld"        # Debian/Ubuntu — should typically show only one active
systemctl is-active ufw firewalld 2>/dev/null

# CIS — ensure loopback traffic is explicitly configured (accept on lo, drop any external traffic claiming to be from 127.0.0.0/8)
sudo iptables -L INPUT -v -n | grep lo

# Verification: don't just trust `ufw status` — confirm the rules are actually being enforced by
# testing from the host itself (a closed port should refuse/timeout, an allowed one should connect)
nc -zv -w2 localhost 22        # should succeed if SSH is allowed
nc -zv -w2 localhost 23        # should fail/refuse if telnet is (correctly) not allowed

# Fallback if ufw itself won't enable (rare, but happens in some nested-virtualization/container
# environments where the kernel netfilter modules aren't fully available) — drop to raw iptables
sudo iptables -P INPUT DROP
sudo iptables -A INPUT -i lo -j ACCEPT
sudo iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT   # only if SSH required
sudo netfilter-persistent save 2>/dev/null || sudo iptables-save | sudo tee /etc/iptables/rules.v4

# Edge case: `ufw enable` can silently fail to persist across reboot if the ufw service itself isn't
# enabled at the systemd level — the rule check above (systemctl is-enabled) catches this, but if
# you do end up rebooting mid-round, re-check ufw status immediately after it comes back up
What this iptables sequence does
-P INPUT DROP sets the default policy for incoming traffic to "drop everything" — a default-deny stance. The -A (append) lines that follow add specific exceptions on top of that default: allow loopback traffic (-i lo), and allow replies to connections you started (ESTABLISHED,RELATED). Order matters — the default-deny should come first, exceptions after, or you'd briefly have zero rules blocking anything.
Expected result:
  • is-enabled prints enabled.
  • Raw iptables -L shows the actual kernel-level rule chains ufw/firewalld generated (useful to eyeball for anything unexpected).
  • fail2ban install/enable behaves like any other package+service.
  • ufw status numbered prefixes each rule with [N] for use with delete <N>.
  • The nc -zv checks print "succeeded" or "Connection refused"/timeout depending on whether that port is actually reachable.
If it fails:
  • is-enabled printing disabled means the firewall rules are active right now but won't survive a reboot — fix with sudo systemctl enable ufw (or firewalld).
  • nc: command not found — install netcat/ncat/nmap-ncat depending on distro, or substitute timeout 2 bash -c "</dev/tcp/localhost/22" && echo open as a netcat-free equivalent for a quick TCP check.
  • If both ufw AND firewalld (or ufw and hand-written iptables) show as active simultaneously, pick ONE and fully disable the other (sudo ufw disable or sudo systemctl disable --now firewalld) — running two rule-management tools at once is itself a CIS finding and makes rules unpredictable.

The default-deny-inbound / allow-outbound policy here matches CIS Benchmark Network Configuration and Firewalls guidance directly. Picking a single firewall tool and ensuring loopback traffic is correctly scoped are both named CIS Level 1 checks that are easy to miss when you're moving fast.

Mint/Ubuntu GUI equivalent: "Firewall Configuration" (gufw) — good fallback for teammates less comfortable with the CLI; toggles the same underlying ufw rules.


7. Services & Open Ports Audit#

  • List all listening sockets and map to owning process:
bash
sudo ss -tulpn
sudo netstat -tulnp     # if netstat/net-tools installed
Expected result:A table of listening TCP/UDP sockets with local address:port and the owning process name/PID in brackets (e.g. users:(("sshd",pid=812,fd=3))).
If it fails:
  • netstat: command not found — it's deprecated/not installed by default on many modern distros (part of the old net-tools package); ss is the actively-maintained replacement and is what you should rely on primarily, netstat here is just a secondary cross-check if it happens to be present.
  • Process names showing as blank/- instead of a name means you're not running as root — re-run with sudo, since a non-root user can see that a port is open but not which process owns it.
  • List enabled services and evaluate each against the README's required list:
bash
systemctl list-unit-files --type=service --state=enabled
systemctl list-units --type=service --state=running
Expected result:Two tables — one of every service configured to start at boot, one of every service currently running right now (these lists can legitimately differ).
If it fails:systemctl: command not found means this is a non-systemd system (older Debian/Ubuntu, or a minimal container-style image) — fall back to service --status-all or inspecting /etc/init.d/ directly, as the next bullet already notes.
  • On very old init-based systems: service --status-all or check /etc/init.d/.
  • Disable/stop/purge unapproved services (do not remove anything the README requires):
bash
sudo systemctl disable --now <service_name>
sudo apt purge -y <package_name>        # Debian/Ubuntu
sudo dnf remove -y <package_name>       # Fedora/RHEL
Expected result:
  • disable --now is silent or prints a couple of "Removed symlink" lines and immediately stops the service.
  • purge/remove print normal package-manager removal output ending in a summary line.
If it fails:
  • "Unit not found" on disable means it's not a systemd service at all (could be a cron-launched process, an xinetd-managed service, or something started by an rc.local-style script) — go find how it's actually being launched instead (check crontab -l, /etc/xinetd.d/, /etc/rc.local).
  • apt purge failing with unmet dependency errors on a package other software depends on — don't force it blindly (--force flags can break the package database); read exactly which dependency is complained about first, since removing a shared library can silently break something the README requires.
  • If a scoring engine only checks for a package, disable --now may not be enough — a purge/uninstall is often required for full points once you're sure it's unauthorized.
  • Cross-reference the "necessary vs. suspicious" port table for triage:
Port(s) Service Typical Verdict
80, 443 HTTP/HTTPS Keep only if a web server is required
22 SSH Keep only if remote admin required
20-21 FTP Usually remove unless explicitly required
23 Telnet Remove — plaintext, essentially always prohibited
25/110/143 Mail (SMTP/POP3/IMAP) Remove unless mail server explicitly required
135, 139, 445 Windows/Samba (SMB) Remove unless file sharing explicitly required
3389 RDP (xrdp) Remove unless remote desktop explicitly required
6667 IRC Remove — common backdoor channel
Anything unrecognized ??? Investigate: sudo ss -tulpn, trace the PID, check the binary path

Things to try / extra points#

bash
# Disable avahi (mDNS) and cups (printing) if not required — common easy points
sudo systemctl disable --now avahi-daemon
sudo systemctl disable --now cups

# Trace a listening port back to the exact binary
sudo lsof -i :<port>
sudo ss -tulpn | grep <port>
readlink -f /proc/<pid>/exe

# systemd security exposure scoring — flags weakly-sandboxed units
systemd-analyze security

# Enumerate systemd sockets (can auto-launch shells on connect — rootkit favorite)
systemctl list-sockets --all

# Check for legacy /etc/rc.local bootup backdoors
cat /etc/rc.local 2>/dev/null

# CIS Benchmark — Services: named list of servers CIS says to disable unless explicitly required
# (autofs, avahi, cups, dhcp, dns/bind, ftp/vsftpd, nfs, rpcbind, rsync, samba, snmp, telnet, tftp, X11, X font server)
for svc in autofs avahi-daemon cups isc-dhcp-server bind9 named vsftpd nfs-server rpcbind rsync smbd snmpd telnet tftpd xinetd; do
  systemctl is-enabled "$svc" 2>/dev/null | grep -q enabled && echo "ENABLED (check against README): $svc"
done

# CIS — ensure mail transfer agent is configured for local-only mail delivery (not accepting remote connections)
# unless the scenario explicitly requires a mail server
sudo ss -lnt | grep -E ':25\b'
Expected result:
  • disable --now avahi-daemon/cups behave like any other service disable.
  • lsof -i :<port> prints the process holding that port (similar info to ss -tulpn but sometimes easier to read for a single port).
  • systemd-analyze security prints a table of every unit with an exposure score (UNSAFE down to SAFE) — informational, not pass/fail.
  • list-sockets prints activation sockets and their listening address.
  • The CIS for loop prints an ENABLED (check against README): line only for services from that list that are actually both installed and enabled — silence means none of those legacy services are active, which is the good outcome.
If it fails:
  • lsof: command not found — it's not always preinstalled; ss -tulpn | grep <port> (already shown above) gives the same PID info without needing to install anything extra.
  • systemd-analyze security refusing to run ("Verb security not implemented on non-service manager") means this systemd is too old for that feature — skip it, it's a nice-to-have, not a required check.
  • If the CIS loop reports something enabled that the README explicitly requires, leave it running — the loop is a checklist prompt to verify against the README, not an instruction to disable everything it lists unconditionally.

The port-triage table above and the "disable unnecessary daemons" approach line up with CIS Benchmark Services guidance, which names almost this exact list of legacy/unnecessary servers (FTP, Telnet, rsync, Samba, NIS, DNS, DHCP, print/mail servers) as Level 1 removals unless the system's role requires them.

CIS Level 2 to try if time allows: CIS also flags leaving client packages installed for protocols you've disabled the server for (e.g., an NIS client, an rsh client) — removing unused client packages too is a Level 2 nice-to-have, not just the server daemons.

If you're not sure a service is needed and the README doesn't mention it explicitly, disable rather than uninstall first — it's reversible and you can confirm nothing broke before doing a full purge.


8. Package Updates#

  • Check network connectivity first — some competition images are offline, in which case update commands will hang or fail; don't waste round time on them.
bash
ping -c 2 8.8.8.8
Expected result:Two ICMP replies with round-trip times, ending in a summary line showing 0% packet loss.
If it fails:
  • 100% packet loss / "Network is unreachable" most often means the competition image genuinely has no internet access (common and expected once scoring starts) — stop here, don't waste round time retrying update commands that will just hang; note in your team's notes that updates couldn't be applied and move to sections that don't need connectivity.
  • If you expect connectivity and don't have it, check ip a for a valid IP and ip r for a default route before concluding the network itself is intentionally cut off.
  • Debian/Ubuntu/Mint:
bash
sudo apt update && sudo apt upgrade -y && sudo apt dist-upgrade -y
sudo apt autoremove -y
Expected result:apt update lists repository fetch progress; upgrade/dist-upgrade list packages being upgraded and end with a summary count; autoremove lists and removes now-unneeded dependency packages.
If it fails:
  • "Could not get lock /var/lib/dpkg/lock" means another package-manager process is already running (a background unattended-upgrade, or a second team member's terminal) — wait for it to finish, or if you're sure nothing legitimate is running, sudo lsof /var/lib/dpkg/lock shows what's holding it.
  • Repository fetch errors (403/404) for a specific source usually mean a third-party/PPA repo listed in /etc/apt/sources.list.d/ is stale or unreachable — comment out just that one entry rather than letting it block updates from the main repos.
  • RHEL/CentOS (yum):
bash
sudo yum update -y
  • Fedora/CentOS 8+/RHEL 8+ (dnf):
bash
sudo dnf update -y
sudo dnf upgrade --refresh -y
Expected result:Both list packages being updated and finish with Complete!.
If it fails:"Cannot find a valid baseurl for repo" on CentOS in particular is common on CentOS 8, whose official mirrors were sunset — if this image is CentOS 8, you may need to point at CentOS Vault mirrors or the image may simply be unable to update at all offline; don't sink excessive round time here if it's clearly a dead-repo issue rather than something you can quickly fix. "Nothing to do" / "No packages marked for update" is a normal, successful outcome meaning the system is already current, not a failure.
  • After updating, re-verify no required service broke (updates can restart daemons with different configs).

Legacy CVE Spot-Check: Shellshock (Bash)#

A recurring historical pattern on older CyberPatriot practice/competition images is an outdated bash vulnerable to Shellshock (CVE-2014-6271 and the related follow-up CVEs) — arbitrary code execution via a specially crafted environment variable containing an exported bash function. This is genuinely more of a legacy/older-image concern: current Ubuntu/Debian LTS releases ship a patched bash by default. But the check costs a few seconds and the fix is a one-line update, so it's worth running on any image that feels dated or hasn't been patched in a while.

  1. Check the installed bash version:
bash
bash --version
Expected result:One line like GNU bash, version 5.1.16(1)-release ....
If it fails:
  • This essentially never errors — if nothing prints, you're not actually in a bash shell (check echo $SHELL / ps -p $$); some minimal images default to dash or sh for scripting even if bash is installed. 2.
  • Run the quick Shellshock test — it should print test only; if it also prints vulnerable, the bash on this box is unpatched:
bash
env x='() { :;}; echo vulnerable' bash -c "echo test"
Expected result on a patched system:
  • just test.
  • Seeing vulnerable printed before test means this bash is exploitable.
If it fails:
  • No real failure mode here beyond the vulnerability itself — if you see vulnerable, proceed to step 3 immediately; this is a genuinely high-value, low-effort fix if it comes back positive. 3.
  • If vulnerable, update bash to the latest patched version for the distro:
bash
sudo apt update && sudo apt install --only-upgrade bash    # Debian/Ubuntu
sudo yum update bash                                        # RHEL/CentOS
Expected result:Normal package upgrade output for the bash package specifically.
If it fails:
  • No internet access (see the ping check earlier in this section) means you can't fetch a patched version — note it as a known, unfixable-offline issue rather than losing time retrying.
  • If apt/yum report bash is already at the "latest" version but the Shellshock test in step 2 still shows vulnerable, this may be one of the follow-up Shellshock CVEs (not the original) that needed a separate, later patch — check the distro's specific advisory for the exact patched version number your family requires. 4.
  • Re-run the test in step 2 to confirm the fix took — it should no longer print vulnerable.

Don't spend a lot of round time hunting for Shellshock on a modern, already-patched image — this is a two-command check. If it comes back clean, move on; if it's vulnerable, it's an easy, high-value fix.

Things to try / extra points#

bash
# List packages with available security updates specifically (Debian/Ubuntu)
apt list --upgradable

# Check for held/pinned packages that silently block updates
apt-mark showhold

# See what would change before committing (dry run)
sudo apt upgrade -s

# RHEL/CentOS/Fedora: list only security-relevant updates
sudo dnf updateinfo list security
sudo yum --security check-update

# CIS Benchmark — Package Management: confirm package signature verification is actually enforced
# (a repo with GPG checking disabled lets an attacker's tampered package install silently)
grep -r "^gpgcheck" /etc/yum.repos.d/*.repo 2>/dev/null          # RHEL/Fedora — expect gpgcheck=1
apt-cache policy 2>/dev/null | grep -i "500\|https\?://"          # sanity-check configured repos are legitimate

# CIS — remove orphaned/leftover packages nothing else depends on
sudo apt autoremove -y                                            # Debian/Ubuntu
sudo dnf autoremove -y                                            # Fedora/RHEL 8+
sudo package-cleanup --leaves 2>/dev/null                          # RHEL 7 (needs yum-utils)
Expected result:
  • apt list --upgradable lists package names with old→new version.
  • apt-mark showhold prints package names held back from upgrades (often empty — that's normal).
  • apt upgrade -s (simulate) prints what WOULD happen without changing anything.
  • gpgcheck grep should show gpgcheck=1 for every repo file, no exceptions.
If it fails:
  • Finding a repo with gpgcheck=0 is a real, scorable finding — fix by changing it to gpgcheck=1 in that .repo file, but be aware the repo may then fail to install packages if it genuinely has no valid signing key configured (in which case that repo itself may be illegitimate and worth investigating further, not just re-enabling checks and moving on).
  • package-cleanup: command not found on RHEL 7 means yum-utils isn't installed (sudo yum install yum-utils -y first) — low priority, skip if offline.

Keeping the system patched (this whole section) is CIS Benchmark Software Updates guidance in its simplest form. The GPG-signature check above maps to CIS Package Management — verifying repositories require signed packages is a named Level 1 control that's separate from just "did you run apt update."


9. Malware & Rootkit Scanning#

  • Install and run rootkit/backdoor detectors:
bash
sudo apt install rkhunter chkrootkit -y     # Debian/Ubuntu/Mint
sudo dnf install rkhunter -y                # Fedora/RHEL (chkrootkit often in EPEL)

sudo rkhunter --update
sudo rkhunter --check
sudo chkrootkit
Expected result:
  • rkhunter --check runs an interactive-looking series of checks (press Enter to move through, or add --sk to skip pauses) ending in a summary of Warning/OK counts, with a full log at /var/log/rkhunter.log.
  • chkrootkit prints one line per check ending in not infected, INFECTED, not tested, or not found.
If it fails:
  • Both tools produce false positives on a perfectly clean, stock system — a "Warning" from rkhunter about a hidden file in /dev, or chkrootkit flagging a legitimate system binary, is common and expected; read the specific warning text and cross-check the file/path it's complaining about before treating it as a real compromise.
  • rkhunter --update failing with a network error just means it can't fetch the latest signature database — it still runs a check with whatever it has, so proceed to --check anyway if offline.
  • Install and run ClamAV for malware signatures:
bash
sudo apt install clamav clamav-daemon -y
sudo systemctl stop clamav-freshclam        # stop the service before manual update if it conflicts
sudo freshclam
sudo clamscan -r /home /tmp /var/tmp
Expected result:
  • freshclam prints database update progress ending in something like "Database updated".
  • clamscan prints a per-file scan line for infected files only, then a summary block (files scanned, infected count).
If it fails:freshclam erroring "Can't connect to port 443" means no internet — ClamAV needs its virus definitions downloaded to be useful at all; if this image is offline and definitions were never pre-loaded, clamscan will still run but with a stale/empty database and won't catch much — note this limitation rather than assuming a clean scan means the system is actually clean. "ERROR: Can't open/parse the config file /etc/clamav/freshclam.conf" after a fresh install sometimes happens because the default config has Example as its first active line (a safety guard) — comment out or delete that line before running freshclam.
  • Research every flag/warning before acting — both rkhunter and chkrootkit produce false positives on stock systems; don't blindly "fix" things they flag.

Things to try / extra points#

bash
# ClamTK — GUI frontend for ClamAV; scoring engines sometimes check for the GUI tool specifically
sudo apt install clamtk -y
# Usage: launch it from the applications menu (or `clamtk` from a terminal), go to the
# "Scan a directory" button on the Home tab, point it at /home (or / for a full scan),
# and let it run. Check "History" afterward to review/clean anything it flagged.
# One competitor's field note: ClamTK being *installed* has sometimes scored points on its own
# even when the CLI clamscan didn't — install it in addition to the CLI tool, not instead of it.

# Full-disk clamscan with logging (slow — run in background, check back later)
sudo clamscan -r / --exclude-dir="^/sys|^/proc" -l /root/clamscan.log &

# Update rkhunter's file property database AFTER you've hardened the system (so future checks baseline against your secured state)
sudo rkhunter --propupd

# CIS Benchmark — System Maintenance / Integrity Checking: AIDE (Advanced Intrusion Detection Environment)
# is the file-integrity tool CIS actually names (rkhunter/chkrootkit/ClamAV are good practice but aren't
# the specific CIS-listed control) — install and initialize it if time allows:
sudo apt install aide aide-common -y      # Debian/Ubuntu/Mint
sudo dnf install aide -y                  # Fedora/RHEL
sudo aideinit                             # or: sudo aide --init  (builds the initial file database)
Expected result:
  • ClamTK installs like any GUI package; launching it shows a simple window with Scan/Update/History tabs.
  • Background clamscan writes progress to the log file you can tail -f later.
  • rkhunter --propupd prints a short confirmation the file properties database was updated.
  • aideinit/aide --init takes a while (it's hashing much of the filesystem) and ends by writing a new database file (often needing a rename from aide.db.new to aide.db before first real use — the tool's own output tells you the exact filenames on this build).
If it fails:
  • aideinit/aide --init can take several minutes on a full disk — that's normal, not a hang; let it finish.
  • If aide (bare command, for a later check-run) says "Couldn't open file /var/lib/aide/aide.db for reading," the init step's output database wasn't renamed/moved to the path aide expects by default — check the init command's own final output line for the exact source filename and copy/rename it into place (commonly sudo cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db).

rkhunter, chkrootkit, and ClamAV are strong practical additions but are not themselves named CIS Benchmark controls. The CIS-named control in this space is ensure AIDE is installed and periodically checked — running all four together covers both the competition-heuristic and CIS-aligned bases.

CIS Level 2 to try if time allows: schedule a recurring AIDE check via cron so file-integrity drift gets caught automatically for the rest of the round, not just once — only worth it if you have time left to actually review the output.

Run the rootkit scan early, before you've made big changes, so its baseline reflects the image as issued — then re-run after hardening to confirm no regressions.


10. Prohibited/Unauthorized Software & Media#

  • Cross-reference the README's "authorized software" list against what's installed:
bash
dpkg -l                     # Debian/Ubuntu/Mint — full package list
rpm -qa                     # RHEL/Fedora/CentOS — full package list
Expected result:A long list of every installed package (name, version, short description for dpkg -l; just names for bare rpm -qa).
If it fails:
  • This basically can't fail — if the list looks suspiciously short, you may be filtering/piping it through something (e.g. | grep with a typo'd pattern) — run it bare first.
  • Pipe to less or redirect to a file (dpkg -l > /root/packages.txt) rather than trying to eyeball hundreds of lines scrolling past.
  • GUI package browsers for a deeper look than the default software center:
bash
sudo apt install synaptic -y && sudo synaptic     # Debian/Ubuntu/Mint
Expected result:Synaptic Package Manager window opens, showing installed/available packages with search/filter and category browsing.
If it fails:
  • Won't install without a network connection and a working repo — if offline, skip this and rely on the dpkg -l/rpm -qa text output instead, which needs nothing extra.
  • If it installs but won't launch under sudo due to a display/X11 permission issue, try launching it from the applications menu instead of the terminal, or sudo -E synaptic to preserve your display environment variables.
  • Search for known hacking/pentest tools and remove any not explicitly authorized:
bash
which nmap zenmap netcat nc ncat john hydra aircrack-ng wireshark tcpdump nikto ophcrack metasploit msfconsole 2>/dev/null
Expected result:Prints the full path for each of these that's actually installed and on PATH; nothing printed for a given name means it's not installed (that's the good outcome for most of these on a normal client image).
If it fails:
  • No error mode here — every hit needs a judgment call, not an automatic removal.
  • Cross-check each hit against the README before removing: tcpdump and nc/netcat in particular are extremely common legitimate default packages or dependencies (see the callout right below this block) — don't strip them out reflexively just because they appear on a "hacking tools" list; verify what's actually unauthorized first.

Don't waste time chasing netcat-openbsd as if it were contraband. Stock Ubuntu/Debian ship netcat-openbsd pre-installed as a dependency of other packages (it provides the plain nc binary used by various system tools), and removing it typically doesn't score points — it's expected default software, not something planted. Confirm what you're looking at before "fixing" it:

bash
dpkg -S $(which nc)              # shows which package owns the nc binary
dpkg -l | grep netcat             # netcat-openbsd = normal; netcat-traditional = often the flagged one

The real red flags are: netcat-traditional specifically (a different package, more often the one competition scoring checks for), a standalone nc/ncat binary sitting somewhere unexpected like a user's home directory or /tmp, or a netcat-family binary that isn't owned by any installed package at all (dpkg -S returns nothing). Those are worth removing; the default netcat-openbsd dependency on a stock Ubuntu box is not.

  • Common blacklist to check/purge (verify against README first — some scenarios legitimately require a web server):
bash
sudo apt purge -y nmap zenmap wireshark tcpdump netcat-traditional nikto ophcrack apache2 nginx lighttpd samba smbclient
Expected result:Normal apt purge output; harmless if some names in the list aren't installed (apt just says "Unable to locate package" or skips it — it won't fail the whole command for one missing name... actually it WILL error out on an unknown/non-existent package name, see below).
If it fails:
  • apt purge errors on the very first name it can't find and stops, potentially skipping the rest of the list — if that happens, remove each package individually so one bad/uninstalled name doesn't block the others: sudo apt purge -y apache2 etc. one at a time.
  • Double check the README before purging apache2/nginx/samba — these are exactly the kind of package a "the box is a required web/file server" scenario needs kept; this blacklist assumes none of them are required, which won't always be true.
  • Search for prohibited media files (often stashed in home directories, especially an "admin" or "Music" folder):
bash
sudo find /home -type f \( -iname "*.mp3" -o -iname "*.mp4" -o -iname "*.avi" -o -iname "*.mkv" -o -iname "*.mov" -o -iname "*.wav" \) 2>/dev/null
  • Search for suspicious downloaded archives that could contain hacking tools:
bash
sudo find /home /tmp /var/tmp -type f \( -name "*.tar.gz" -o -name "*.tgz" -o -name "*.zip" -o -name "*.deb" -o -name "*.rpm" \) 2>/dev/null
Expected result:A list of matching file paths, or nothing if there's genuinely no media/archives present (a clean result either way).
If it fails:
  • No real error mode for find itself; a huge/slow result on a disk with lots of legitimate content just means you need to eyeball more carefully — pipe to | less or redirect to a file rather than scrolling.
  • Remember find searches are case-sensitive without -iname; the media search already uses -iname (case-insensitive) correctly, but if you write your own variant, matching *.MP3 and *.mp3 both requires -iname, not -name.
  • Remove Samba/SMB unless explicitly required (classic vulnerable/unauthorized service):
bash
sudo apt purge -y samba samba-common smbclient
Expected result:Normal purge output, package and its config files removed.
If it fails:
  • Same README caveat as the blacklist above — a "file server" scenario likely needs Samba kept and hardened instead of removed; verify first.
  • If other installed packages depend on samba-common (some desktop file-sharing integrations do), apt will list them as also being removed — read that list before confirming, in case something required gets swept up as a dependency.

Things to try / extra points#

bash
# Broaden the media search to the whole filesystem (slower, but catches files hidden outside /home)
sudo find / -xdev -type f \( -iname "*.mp3" -o -iname "*.mp4" -o -iname "*.avi" \) 2>/dev/null

# Catch renamed/extension-less contraband by content type, not filename
sudo find /home -type f -exec file {} \; | grep -iE "audio|video"

# List all installed packages sorted by install date (spot recently-added unauthorized tools fast)
grep " install " /var/log/dpkg.log | sort               # Debian/Ubuntu (log-based)
rpm -qa --last | head -30                                 # RHEL/Fedora

# Confirm Firefox is the default browser and check its security settings if the scenario mentions it
xdg-settings get default-web-browser
Expected result:
  • The whole-filesystem media search prints matches beyond just /home (e.g. stashed in /tmp or /opt).
  • The file-based content search prints filename + detected type for anything whose actual content is audio/video, catching renamed files a plain extension search misses.
  • The dpkg-log/rpm history commands print recently-installed packages with timestamps, newest last.
  • xdg-settings prints a .desktop filename identifying the default browser (e.g. firefox.desktop).
If it fails:
  • The file-based search can be slow across a big /home — that's expected, not a hang.
  • grep " install " /var/log/dpkg.log returning nothing means the log has already rotated past your window of interest — check /var/log/dpkg.log.1 or .gz rotated logs too (zgrep " install " /var/log/dpkg.log.*.gz).
  • xdg-settings: command not found on a minimal/no-GUI image just means there's no desktop environment to have a "default browser" concept — not applicable, skip it.

Don't just apt remove — use apt purge so leftover config files don't leave partial credit on the table, and follow with apt autoremove to clear orphaned dependencies.


11. File Permissions & SUID/SGID Audits#

  • Scan for SUID/SGID binaries and compare against a known-good baseline (unexpected SUID root binaries are a common planted vulnerability):
bash
sudo find / -xdev -type f \( -perm -4000 -o -perm -2000 \) -exec ls -l {} \; 2>/dev/null
What this find command does
This walks the whole filesystem (-xdev stops it from crossing into other mounted drives/network shares) looking for files (-type f) that have the SUID (-perm -4000) or SGID (-perm -2000) bit set — these are programs that run with the file owner's/group's permissions instead of the user running them, a classic privilege-escalation target. -exec ls -l {} \; runs `ls -l` on every match found so you can see who owns each one.
Expected result:A list of binaries with an s in their permission string (e.g. -rwsr-xr-x) — on a stock system this is normally a fairly short, predictable list (passwd, sudo, su, mount, ping, a few others) since SUID/SGID lets a binary run with the file owner's (often root's) privileges regardless of who invokes it.
If it fails:
  • No real error mode — a long or unfamiliar-looking result is the actual finding to investigate, not a script problem.
  • -xdev intentionally keeps the search from crossing into mounted filesystems (network shares, other drives) — if you specifically need to check those too, drop -xdev, but expect a much slower search.
  • Anything in this list you don't recognize deserves a dpkg -S <path> / rpm -qf <path> check — if it's not owned by any installed package, that's a strong sign it was planted, not shipped.
  • Remove unnecessary SUID/SGID bits from anything that shouldn't have them:
bash
sudo chmod u-s /path/to/binary
sudo chmod g-s /path/to/binary
Expected result:Silent on success; re-run the find above and the binary's permission string should no longer show s.
If it fails:
  • Removing the SUID bit from a binary that genuinely needs it (like passwd or sudo itself) will break that program's ability to do its privileged job — if something stops working right after a chmod here, that's very likely the cause; restore with sudo chmod u+s /path/to/binary.
  • Only strip bits from binaries you don't recognize or that clearly don't need elevated privileges (e.g. a random script someone dropped in /opt with SUID set) — when in doubt about a standard system binary, leave it alone.
  • Verify critical file ownership/permissions:
File Expected Perms Expected Owner
/etc/passwd 644 root:root
/etc/shadow 640 or 600 root:shadow (or root:root)
/etc/group 644 root:root
/etc/gshadow 640 or 600 root:shadow
/etc/sudoers 440 root:root
bash
ls -l /etc/passwd /etc/shadow /etc/group /etc/gshadow /etc/sudoers
sudo chmod 644 /etc/passwd && sudo chmod 640 /etc/shadow
Expected result:ls -l shows the current permission strings; after the chmods, re-running it should show -rw-r--r-- on /etc/passwd and -rw-r----- on /etc/shadow.
If it fails:
  • "Operation not permitted" on the chmod means you're not actually running as root/sudo despite the sudo prefix — check for a typo dropping the sudo, or that your account genuinely has sudo rights.
  • If /etc/shadow's group ownership isn't shadow after the chmod (permissions look right but ls -l shows a different group), fix ownership separately: sudo chown root:shadow /etc/shadow.
  • Set restrictive home directory permissions for regular users:
bash
for i in $(awk -F: '$3 > 999 && $3 < 65534 {print $1}' /etc/passwd); do
  [ -d /home/${i} ] && sudo chmod -R 750 /home/${i}
done
What this chmod command does
-R applies the change recursively (the folder and everything inside it, not just the top-level folder). 750 breaks down as three digits = owner/group/others: 7 (owner: read+write+execute), 5 (group: read+execute), 0 (others: nothing) — so only the account owner can fully use their home folder, others on the box get zero access to it.
Expected result:Silent on success; ls -ld /home/* afterward shows drwxr-x--- on each processed home directory.
If it fails:
  • If a user's home directory is somewhere other than /home/<username> (some accounts, especially service-adjacent ones, use non-standard home paths), the [ -d /home/${i} ] test just silently skips them — check /etc/passwd's 6th field for the actual home path if a specific user seems to have been missed.
  • A recursive chmod -R 750 on a large home directory can take a while and will also change permissions on every file/subfolder inside — if a required application inside that home directory needs different (e.g. more permissive) permissions on a specific subfolder to function, you may need to re-loosen that one path afterward.

Things to try / extra points#

bash
# Diff current SUID list against a saved baseline from earlier in the competition
sudo find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null | sort > /root/suid_now.txt
diff /root/suid_baseline.txt /root/suid_now.txt

# Find files/directories writable by "others" that also have SUID/SGID (very high risk combo)
sudo find / -xdev -type f -perm -4002 2>/dev/null

# Directory-specific perms called out in reference material
ls -ld /tmp /var/tmp /boot/grub /boot/grub2
# /tmp, /var/tmp: expect world-writable WITH sticky bit (drwxrwxrwt)
# /boot/grub(2): expect root read/write only

# CIS Benchmark — System File Permissions: check the backup copies too, not just the live files
# (/etc/passwd-, /etc/shadow-, /etc/group-, /etc/gshadow- are created automatically by useradd/passwd
# and are frequently forgotten, but CIS checks them with the same expected permissions as the live file)
ls -l /etc/passwd- /etc/shadow- /etc/group- /etc/gshadow- 2>/dev/null

# CIS — permissions on cron directories/files (root-only)
ls -ld /etc/crontab /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly /etc/cron.d
sudo chmod og-rwx /etc/crontab /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly /etc/cron.d

# CIS — GRUB bootloader config should not be world-readable (may contain recovery-mode boot params)
ls -l /boot/grub/grub.cfg /boot/grub2/grub.cfg 2>/dev/null
sudo chmod og-rwx /boot/grub/grub.cfg 2>/dev/null
sudo chmod og-rwx /boot/grub2/grub.cfg 2>/dev/null
Expected result:
  • diff prints nothing if the SUID list is unchanged from baseline (good), or </> prefixed lines showing exactly what was added/removed if it changed.
  • The -perm -4002 search (SUID + world-writable together) should almost always come back completely empty on a healthy system — any hit here is a serious, high-priority finding.
  • The ls -ld checks print current permission strings to compare against the noted-expected values in the comments.
If it fails:
  • diff: /root/suid_baseline.txt: No such file or directory just means you never saved a baseline earlier in the round — not a real problem, just means you're doing a one-time check now instead of a differential one; save one now (sort > /root/suid_baseline.txt on the current find output) so future re-checks this round have something to diff against.
  • If /boot/grub2/grub.cfg doesn't exist, this is a GRUB Legacy or GRUB2-under-a-different-path system — the "2>/dev/null" already suppresses the harmless "no such file" noise for whichever path doesn't apply to this distro.

The /etc/passwd, /etc/shadow, /etc/group, /etc/gshadow, and /etc/sudoers permission checks earlier in this section are directly CIS Benchmark System File Permissions controls. The backup-file (passwd-/shadow-/etc.), cron directory, and GRUB config permission checks above are the same control family but commonly skipped because they're easy to forget exist.

A stray SUID bit on something like /usr/bin/find, /usr/bin/vim, or /usr/bin/python3 is a classic privilege-escalation plant — treat any SUID binary you don't recognize as suspicious until proven otherwise.


12. Cron Jobs & Scheduled Tasks Audit#

  • Check every user's crontab, not just root's:
bash
crontab -l
sudo crontab -l
for u in $(cut -f1 -d: /etc/passwd); do echo "== $u =="; sudo crontab -u "$u" -l 2>/dev/null; done
Expected result:Each user's crontab lines (if any), or no crontab for <user> for accounts without one — that message on 2>/dev/null-suppressed output in the loop just means clean output with headers only.
If it fails:
  • "no crontab for X" is not an error, it's the expected message for the majority of accounts; don't mistake it for a broken command.
  • crontab -l (no sudo, no -u) only ever shows YOUR OWN crontab — the loop with sudo crontab -u "$u" is what actually covers every account, so don't stop at the first two lines and assume you've audited everyone.
  • Check system-wide cron locations:
bash
cat /etc/crontab
ls -la /etc/cron.d/ /etc/cron.daily/ /etc/cron.hourly/ /etc/cron.weekly/ /etc/cron.monthly/
ls -la /var/spool/cron/crontabs/          # Debian/Ubuntu
ls -la /var/spool/cron/                   # RHEL/Fedora
ls -la /var/spool/anacron/
Expected result:File listings of scripts/entries in each directory; /etc/crontab prints the system crontab's active lines.
If it fails:
  • "No such file or directory" on whichever spool path doesn't match this distro's family (Debian uses crontabs, RHEL doesn't) is expected — only one of those two spool paths will exist on any given box.
  • An empty ls on a cron.daily/cron.weekly/etc directory is normal on a fresh/minimal image, not a sign anything's broken.
  • Check for persistence via systemd timers (increasingly used instead of / alongside cron):
bash
systemctl list-timers --all
Expected result:A table of timer units, their next/last trigger times, and the unit they activate.
If it fails:
  • "command not found" confirms a non-systemd system — cron/at (checked above/below) are this system's only scheduling mechanism, so this check simply doesn't apply.
  • An empty-looking table (just headers, "0 timers listed") is a legitimate clean result, not a broken command.
  • Remove/comment out any unauthorized scheduled jobs, especially ones that download files, open shells, or run as root unexpectedly.

Things to try / extra points#

bash
# Grep every cron location at once for suspicious keywords (reverse shells, curl|bash, base64)
sudo grep -RniE "curl|wget|base64|/dev/tcp|nc -e|bash -i" /etc/cron* /var/spool/cron* 2>/dev/null

# Check for at-jobs too (cron's lesser-known cousin)
atq
sudo at -l

# CIS Benchmark — System Maintenance: cron/at should be restricted via allow-lists, not left open to everyone
ls -la /etc/cron.allow /etc/at.allow /etc/cron.deny /etc/at.deny 2>/dev/null
# CIS expects cron.allow/at.allow to exist (root-owned, 640 or more restrictive) and cron.deny/at.deny to NOT exist
sudo rm -f /etc/cron.deny /etc/at.deny
echo root | sudo tee /etc/cron.allow /etc/at.allow
sudo chmod 640 /etc/cron.allow /etc/at.allow
Expected result:
  • The grep prints any matching suspicious lines found (empty = clean).
  • atq/at -l list pending at-jobs (often empty — that's normal, at-jobs are less commonly used than cron).
  • The cron.allow/at.allow block ends with those two files existing, containing root (add other authorized usernames on separate lines if needed), owned appropriately, mode 640.
If it fails:
  • A hit from the keyword grep needs manual review, not automatic deletion — open the specific cron file/line it flagged and confirm it's actually malicious before removing (some legitimate maintenance scripts do use curl/wget for legitimate reasons).
  • atq/at: command not found means the at package isn't installed — if it's not installed, there's nothing to restrict via at.allow, that part of this block doesn't apply.
  • If restricting cron access unexpectedly stops a required scheduled job from running for a non-root authorized user, add that username to /etc/cron.allow too (one per line).

The allow/deny-file check above is a specific CIS Benchmark System Maintenance control that's easy to miss because most systems don't have cron.allow/at.allow configured out of the box.

Systemd timers can silently replace a cron job an attacker removed to look "clean" — always check systemctl list-timers --all even if crontabs look empty.


13. World-Writable Files & Unowned Files#

  • Find world-writable files (excludes directories, which need the sticky-bit-aware version below):
bash
sudo find / -xdev -type f -perm -0002 -exec ls -l {} \; 2>/dev/null
Expected result:
  • A (hopefully short/empty) list of world-writable regular files.
  • Any hit is worth individually reviewing.
If it fails:No error mode — a long result on a heavily-used image with lots of shared/collaborative directories is a real finding to work through, not a broken search; prioritize anything outside /tmp//var/tmp (which are expected to be writable) and especially anything under /etc, /usr, or a service's config directory.
  • Find world-writable directories that lack the sticky bit (real risk — anyone can delete/rename others' files inside them):
bash
sudo find / -xdev -type d \( -perm -0002 -a ! -perm -1000 \) -print 2>/dev/null
  • Find files/dirs with no valid owner or group (often leftover from a deleted account or a dropped tool):
bash
sudo find / -xdev \( -nouser -o -nogroup \) -print 2>/dev/null
Expected result:
  • Both should come back with few or no hits on a healthy image.
  • Unowned files specifically are worth flagging even if their permissions look otherwise fine — a UID/GID with no matching /etc/passwd//etc/group entry usually means the owning account was deleted while it still owned files, or (less innocently) a file was planted using a UID that was never actually created as a real account.
If it fails:No real failure mode; if the unowned-files search returns something tied to an account you just disabled/removed earlier in Section 3, that's expected fallout from your own cleanup, not a new threat — just confirm it makes sense given what you already changed.
  • Fix world-writable files by removing the o+w bit unless there's a specific reason (e.g., /tmp) for it to remain:
bash
sudo chmod o-w /path/to/file
Expected result:Silent on success.
If it fails:
  • "Operation not permitted" means you're not root/sudo, or (rarer) the file has the immutable attribute set (chattr +i) blocking even root from modifying it — check with lsattr /path/to/file and clear it first with sudo chattr -i /path/to/file if so (see Section 14's coverage of this same trick used for persistence).
  • Don't remove write access from /tmp, /var/tmp, or /dev/shm themselves — those are SUPPOSED to be world-writable; the fix there is ensuring the sticky bit is set (see below), not removing write access.

Things to try / extra points#

bash
# Restrict search to a single mount without -xdev by specifying the path (faster on multi-disk images)
sudo find /home -type f -perm -0002 -ls 2>/dev/null

# Re-apply the sticky bit to shared temp directories instead of removing write access outright
sudo chmod +t /tmp /var/tmp /dev/shm
Expected result:
  • The /home-scoped search is much faster than a full / scan since it's a smaller tree.
  • chmod +t is silent; confirm with ls -ld /tmp showing a trailing t in the permission string (drwxrwxrwt).
If it fails:No real failure mode for either — if ls -ld still shows a lowercase t missing (shows drwxrwxrwx with no sticky bit) after the chmod, double check you didn't typo the path or accidentally target a different directory.

-xdev keeps find from crossing into /proc, /sys, and other mounted filesystems — without it these scans take far longer and return noise.


14. Bash History, Hidden Files & Shell Profile Review#

  • Review shell history for evidence of attacker commands, credentials, or added persistence:
bash
cat /home/*/.bash_history /root/.bash_history 2>/dev/null
cat /home/*/.sh_history 2>/dev/null
Expected result:The raw command history of each account, most recent commands at the bottom.
If it fails:An empty or very short history file for an account that's clearly been used is itself suspicious — HISTSIZE=0, a symlink to /dev/null, or a manually-cleared history are all attacker cleanup techniques; check ls -la ~/.bash_history for the account (a symlink shows -> in the listing) rather than assuming "empty = nothing happened." Bash also only writes history to disk on a clean shell exit by default — a still-open session's most recent commands may not be in the file yet; history (no args, run inside that live session) shows the in-memory version if you have access to it.
  • Search for hidden files/directories planted outside normal locations:
bash
sudo find / -xdev -name ".*" -type f 2>/dev/null | grep -vE "^/home|^/root|^/etc"
Expected result:A short list (ideally empty or near-empty) of dotfiles living somewhere unusual, like /tmp, /var, or /opt — legitimate dotfiles mostly live under /home, /root, or /etc, which this search deliberately excludes to cut noise.
If it fails:No error mode; a longer list than expected on a system with lots of installed software is normal (many packages drop dotfiles under /var/lib or similar) — focus your attention on hits in genuinely unexpected places like /tmp or a random directory under /opt, not every single dotfile the search turns up.
  • Check shell profile/rc files for malicious aliases, function overrides, or $PATH hijacks:
bash
grep -rnE "alias|export PATH|function" /etc/profile /etc/bash.bashrc /etc/environment /home/*/.bashrc /root/.bashrc 2>/dev/null
Expected result:
  • Matching lines with filename and line number prefixed.
  • Some hits are completely normal (distros ship a few default aliases like alias ll='ls -alF') — you're looking for anything that redefines a common command name (ls, sudo, cd) to do something unexpected, or a PATH export that prepends a world-writable directory ahead of /usr/bin.
If it fails:
  • No error mode; this is a read-and-judge task, not pass/fail.
  • If you find a genuine PATH hijack (a directory like /tmp or a user-writable folder placed before /usr/bin in $PATH), remove that entry from the offending rc file and confirm with echo $PATH in a fresh shell that the malicious directory is gone from the search order.
  • Check for immutable-attribute files used to persist malicious configs against deletion:
bash
sudo lsattr -R /etc /var /home 2>/dev/null | grep '\----i'
# unlock a flagged file before editing/removing:
sudo chattr -i /path/to/file
Expected result:lsattr prints an attribute-flags column followed by the path for every file under those trees; the grep narrows it to just files with the immutable (i) flag set — on a stock system this list is normally empty or very short.
If it fails:
  • lsattr: Inappropriate ioctl for device for some files/filesystem types (some virtual/network filesystems don't support extended attributes) is expected noise, not a real error — it just means that particular file can't be checked this way.
  • If a flagged file resists a chattr -i (still shows immutable after running it), confirm you're running as root — the immutable attribute itself is one of the few things that blocks even root's normal write/delete permissions until explicitly cleared.
  • Check /etc/ld.so.preload and /etc/ld.so.conf.d/ for forced shared-library injection (classic rootkit technique):
bash
cat /etc/ld.so.preload 2>/dev/null
ls -la /etc/ld.so.conf.d/
Expected result:
  • On a clean system, /etc/ld.so.preload typically doesn't exist at all (the cat prints nothing and errors silently due to 2>/dev/null — that's the good outcome).
  • ld.so.conf.d/ normally contains only recognizable, package-installed .conf files.
If it fails:Any output from the cat means the file exists and is actively forcing a shared library into every dynamically-linked process on the system — per the callout below, treat a non-standard path in this file as an immediate, high-priority removal (sudo rm /etc/ld.so.preload), then sudo ldconfig to refresh the linker cache afterward.

If /etc/ld.so.preload exists and references a non-standard .so path, delete it immediately — it forces that library into every process on the system.

Things to try / extra points#

bash
# Search running process environments for leaked plaintext credentials
sudo strings /proc/*/environ 2>/dev/null | grep -iE "pass|pwd|secret"

# Look for PATH hijacks specifically (e.g., a writable dir prepended before /usr/bin)
echo $PATH
for u in /home/*; do sudo -u "$(basename $u)" bash -c 'echo $PATH' 2>/dev/null; done

# Audit PAM config for injected .so files that don't live in the real security library dir
grep -r "so" /etc/pam.d/ | grep -v "/lib/" | grep -v "/usr/lib/"
ls -la /lib/x86_64-linux-gnu/security/ 2>/dev/null
Expected result:
  • strings /proc/*/environ | grep -i "pass|pwd|secret" prints any matches found in currently-running processes' environment variables (empty is the good outcome — credentials shouldn't be sitting in plaintext env vars).
  • The PATH checks print each account's effective search path.
  • The PAM .so audit's final grep should print nothing (every referenced module lives in the standard library path); the ls shows what's actually installed there for comparison.
If it fails:
  • "Permission denied" reading /proc/<pid>/environ for processes you don't own is expected even as root for some kernel/protected processes — not a real error, just inaccessible by design.
  • A per-user PATH that differs from root's isn't automatically suspicious (some accounts legitimately add a local ~/bin) — only flag it if a world-writable directory appears BEFORE the standard system paths.
  • Any .so reference in a PAM config file that survives the grep -v filters (meaning it's NOT in /lib/ or /usr/lib/) is worth investigating directly — open that specific pam.d file and check where the referenced module actually points.

15. Log Review#

  • Review authentication logs for brute-force attempts, unexpected logins, or sudo abuse:
bash
grep sshd.*Failed /var/log/auth.log | less        # Debian/Ubuntu/Mint
sudo grep "Failed password" /var/log/secure       # RHEL/Fedora/CentOS
Expected result:Matching lines paged through less, each showing timestamp, source IP, and username attempted.
If it fails:
  • "No such file or directory" on /var/log/auth.log on a Debian/Ubuntu-FAMILY box usually means logging has moved to the systemd journal only (no flat-file auth log written) — use journalctl instead: sudo journalctl -u ssh | grep Failed.
  • Empty results with the file present just means no failed attempts are logged yet, or the log rotated — check /var/log/auth.log.1 too.
  • Review the broader log set:
Log Path (Debian/Ubuntu) Path (RHEL/Fedora)
Auth/security /var/log/auth.log /var/log/secure
Boot /var/log/boot.log /var/log/boot.log
Kernel /var/log/kern.log journalctl -k
Daemon /var/log/daemon.log journalctl -u <service>
General/syslog /var/log/syslog /var/log/messages
Debug /var/log/debug
bash
sudo less /var/log/auth.log
sudo less /var/log/syslog
journalctl -xe
journalctl -k
Expected result:
  • less-paged log contents (use /searchterm inside less to search, q to quit).
  • journalctl -xe jumps to the end of the systemd journal with extra context added to some entries; journalctl -k shows kernel-ring-buffer messages only.
If it fails:
  • File-not-found on /var/log/syslog (RHEL family uses /var/log/messages instead — check the table above) is a distro-family mismatch, not an error in the command itself.
  • journalctl complaining about needing to be in the systemd-journal group or run as root — prefix with sudo.
  • Set up auditd for ongoing kernel-level auditing:
bash
sudo apt install auditd -y      # Debian/Ubuntu/Mint
sudo dnf install audit -y       # Fedora/RHEL
sudo auditctl -e 1
sudo systemctl enable --now auditd
Expected result:Normal install output; auditctl -e 1 prints AUDIT_STATUS: enabled=1 ...; systemctl enable --now auditd starts the service silently or with a couple of "Created symlink" lines.
If it fails:
  • "Unable to set enabled flag to 1" on auditctl sometimes happens if auditd is already running and the flag is locked (a hardened/immutable audit config sets -e 2, which can't be changed without a reboot) — check sudo auditctl -s for current status first, this may already be exactly what you want.
  • Package name differs by family — Debian/Ubuntu is auditd, Fedora/RHEL is audit (as shown) — using the wrong name for the distro gives "unable to locate package."
  • Review/edit audit rules at /etc/audit/auditd.conf (or /etc/audit.d/auditd.conf on some older doc references).

Things to try / extra points#

bash
# Quick summary of failed vs. accepted SSH logins
sudo grep -c "Failed password" /var/log/auth.log
sudo grep -c "Accepted password\|Accepted publickey" /var/log/auth.log

# journalctl equivalents work across both families when logs are systemd-journal-based
journalctl -u ssh --since "1 hour ago"
journalctl -p err -b

# Check who has run sudo recently
sudo grep "sudo:" /var/log/auth.log | tail -30

# CIS Benchmark — Logging and Auditing: specific auditd watch rules CIS names explicitly
# (time changes, identity/user-group changes, network config changes, login/logout events,
# discretionary access control / permission changes, sudoers changes, kernel module load/unload)
cat << 'EOF' | sudo tee -a /etc/audit/rules.d/cis.rules
-w /etc/localtime -p wa -k time-change
-w /etc/passwd -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k scope
-w /etc/sudoers.d/ -p wa -k scope
-w /var/log/lastlog -p wa -k logins
-w /etc/hosts -p wa -k network-config
-w /etc/network/ -p wa -k network-config
EOF
sudo augenrules --load 2>/dev/null || sudo service auditd restart

# CIS — ensure log rotation is configured (prevents disk exhaustion, keeps history around long enough to review)
cat /etc/logrotate.conf | grep -E "weekly|rotate|compress"
Expected result:
  • The failed/accepted login counts print a single number each.
  • journalctl -u ssh --since "1 hour ago" shows recent SSH-unit log entries; journalctl -p err -b shows error-priority-or-worse messages since the last boot.
  • The heredoc writes a new rules file and prints nothing; augenrules --load (or the service restart fallback) applies it silently.
  • The logrotate.conf grep shows the configured rotation cadence/retention.
If it fails:
  • grep -c returning 0 for accepted logins on a box you know has been logged into is a sign the log doesn't cover that time window (rotated out) — check /var/log/auth.log.1 or use journalctl instead, which retains more history by default in many configurations.
  • If augenrules --load isn't found, auditd on this build may use a different rule-loading mechanism — the service auditd restart fallback re-reads rules on most versions either way.
  • A custom rules file that doesn't take effect after either command may need a full reboot on very old auditd versions that don't support live rule reloads at all.

The auditd setup already in this section is CIS Benchmark Logging and Auditing territory in general — the specific watch rules above (identity files, sudoers, time changes, network config) are the named CIS Level 1/2 audit rules, not just "auditd is running." Log rotation is a separate, easy-to-miss CIS System Maintenance control.

CIS Level 2 to try if time allows: CIS also names rules for tracking successful/unsuccessful file chmod/chown/setxattr calls (permission-change auditing) and kernel module loading (init_module/delete_module syscalls) — broader coverage, but the ruleset gets noisy fast, so only add these if you have time to actually read the resulting logs.

Don't truncate or delete logs to "clean up" — scoring engines and forensics questions often expect the original log content to still be present and reviewable.


16. Auto-Updates Configuration#

  • Enable unattended/automatic security updates so the system keeps patching itself for the remainder of the competition window.

Debian/Ubuntu/Mint:

bash
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades
Expected result:dpkg-reconfigure opens a text-mode dialog asking "Automatically download and install stable updates?" — select Yes.
If it fails:
  • If the dialog doesn't appear (some minimal/headless terminals render it oddly), it's safe to skip the interactive step and just hand-edit /etc/apt/apt.conf.d/20auto-upgrades directly to the two lines shown below instead — same end result.
  • dpkg-reconfigure: unable to re-open stdin when run through certain non-interactive shells/scripts — run it from a normal interactive terminal session instead. Verify /etc/apt/apt.conf.d/20auto-upgrades contains:
shell
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
Expected result:Both lines present with value "1" (confirm with cat /etc/apt/apt.conf.d/20auto-upgrades).
If it fails:If the file doesn't exist at all, create it with exactly those two lines using a text editor or sudo tee — it's a plain text file, no special generator required.

RHEL/CentOS/Fedora:

bash
sudo dnf install dnf-automatic -y
sudo systemctl enable --now dnf-automatic.timer
Expected result:Normal install output; the timer shows as active in systemctl status dnf-automatic.timer.
If it fails:By default dnf-automatic only downloads and reports updates without installing them — check /etc/dnf/automatic.conf for apply_updates = no and change it to yes if you actually want it to install, not just notify, since the default behavior can look like it "isn't working" when it's actually working as configured.

Things to try / extra points#

bash
# Confirm the unattended-upgrades service/timer is actually active, not just installed
systemctl status unattended-upgrades.service
systemctl status dnf-automatic.timer

# Force a dry-run to confirm it will actually pull updates
sudo unattended-upgrade --dry-run -d
Expected result:
  • systemctl status shows active (running) or active (waiting) for a timer.
  • The dry-run prints verbose debug output showing which packages it WOULD upgrade, without actually installing anything.
If it fails:
  • "Unit not found" for whichever service name doesn't apply to this distro family is expected — only one of the two applies.
  • The dry-run erroring about a lock file already held means a real (non-dry-run) unattended-upgrade is already in progress in the background — wait for it or check ps aux | grep unattended before assuming something's broken.

This section extends the CIS Benchmark Package Management/Software Updates control area covered in Section 8 — CIS cares that updates are applied regularly, and automating that (rather than relying on someone remembering to re-run apt upgrade) is the practical way to keep satisfying that control for the rest of the competition window.

Mint/Ubuntu GUI equivalent: Update Manager → Settings → "Automatically check for updates" and the "Install security updates without confirmation" option.


17. GUI-Based Tool Notes (Mint/Ubuntu)#

For teams (or moments) where the terminal isn't the fastest path, these GUI tools cover the same ground:

Task GUI Tool Notes
Users & Groups Users and Groups (gnome-system-tools / Mint's built-in "Users and Groups") Add/remove users, change passwords, group membership
Firewall gufw ("Firewall Configuration") Front-end for ufw
Software updates Update Manager / Software Updater Equivalent of apt update && apt upgrade
Package management (deep) Synaptic Package Manager Sort by install date/type; find things Software Manager hides
Services BUM (Boot-Up Manager, older) or systemctl GUI front-ends where available Enable/disable services graphically
Antivirus ClamTK GUI frontend for ClamAV — install even if you also run CLI clamscan, since scoring engines sometimes check for the GUI package specifically
bash
sudo apt install gnome-system-tools gufw synaptic clamtk bum -y
Expected result:Normal apt install output for all five packages.
If it fails:
  • "Unable to locate package bum" — Boot-Up Manager is genuinely obsolete and missing from modern Ubuntu/Debian repos; that's expected, not a real problem — just drop it from the install list and rely on systemctl-based service management instead.
  • gnome-system-tools similarly may not exist on very new Ubuntu releases (its functionality got folded into GNOME Settings) — if it's missing, use the Settings app's Users panel instead.
  • If any single package name in the list is unavailable, apt may abort the whole install command — install the remaining valid names individually rather than let one bad name block the rest.

Things to try / extra points#

  • Even if you're comfortable in the terminal, install the GUI packages anyway — some scoring checks look for the package itself (e.g., clamtk, gufw) being present, independent of whether you use the GUI.
  • Use Synaptic's "sort by installation date" view to spot recently added unauthorized packages fast.

18. Kernel & Sysctl Hardening#

Snapshot reminder: sysctl edits, /etc/fstab mount-flag changes, and module blacklisting in this section can render the system unbootable or unusable if you fat-finger a value. Take a VM snapshot before you start this section, and again before any reboot you use to test that changes persisted.

  • Apply the standard network/kernel hardening block via /etc/sysctl.d/99-security.conf. Rather than one 25-line paste, build it up in labeled groups so you know what each chunk is for and can skip a group if it doesn't fit your scenario:

Group 1 — stop this box from routing/forwarding traffic for others (a workstation/server shouldn't silently act as a router):

bash
cat << 'EOF' | sudo tee /etc/sysctl.d/99-security.conf
# --- Group 1: routing/forwarding ---
net.ipv4.ip_forward = 0
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
EOF

Expected result (this and every following tee/tee -a heredoc block in this section): the heredoc's content echoes back to the terminal (that's tee's normal behavior) and is written/appended to /etc/sysctl.d/99-security.conf. If it fails: Using plain tee (not tee -a) on any group after the first will overwrite the file instead of appending — Group 1 correctly uses plain tee to create the file fresh, every group after it correctly uses tee -a; if you re-run Group 1's command later by mistake, it'll wipe out Groups 2-6 you already added, so if you need to redo Group 1, switch it to tee -a too. "Permission denied" writing to /etc/sysctl.d/ means the sudo didn't apply to the whole pipeline correctly — the sudo needs to be on the tee command specifically (as shown), not on cat, since it's tee that's doing the actual privileged write. Group 2 — source-routing & spoofing protection (rejects packets that specify their own route, and packets claiming to be from an address that couldn't actually reach this interface):

bash
cat << 'EOF' | sudo tee -a /etc/sysctl.d/99-security.conf
# --- Group 2: source routing / anti-spoofing ---
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
EOF

Group 3 — ICMP/broadcast hardening (stops this box from being used as a reflector in ping-flood/smurf-style attacks, and logs obviously-forged "Martian" packets):

bash
cat << 'EOF' | sudo tee -a /etc/sysctl.d/99-security.conf
# --- Group 3: ICMP / broadcast ---
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.conf.all.log_martians = 1
EOF

Group 4 — SYN flood / basic TCP hardening (makes a denial-of-service via half-open connections much harder):

bash
cat << 'EOF' | sudo tee -a /etc/sysctl.d/99-security.conf
# --- Group 4: TCP / SYN flood ---
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 2048
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 5
EOF

Group 5 — IPv6 disablement (optional — only if the scenario has no IPv6 requirement):

bash
cat << 'EOF' | sudo tee -a /etc/sysctl.d/99-security.conf
# --- Group 5: IPv6 (skip/remove this group if IPv6 connectivity is required) ---
net.ipv6.conf.all.disable_ipv6 = 1
EOF

Group 6 — kernel exploit mitigations (ASLR, restricting kernel pointer/log leakage, blocking core dumps from world-readable crash files):

bash
cat << 'EOF' | sudo tee -a /etc/sysctl.d/99-security.conf
# --- Group 6: kernel exploit mitigation ---
kernel.randomize_va_space = 1
kernel.yama.ptrace_scope = 2
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
fs.suid_dumpable = 0
EOF

Apply all six groups at once and verify they loaded without error:

bash
sudo sysctl -p /etc/sysctl.d/99-security.conf
Expected result:Each setting from the file echoed back with its new value (e.g. net.ipv4.ip_forward = 0) — this confirms both that the file parsed correctly AND that the kernel accepted each value live, without needing a reboot.
If it fails:
  • sysctl: cannot stat /proc/sys/net/ipv6/...: for any IPv6 line — happens if IPv6 is already fully disabled at the kernel/boot level (e.g. via a boot parameter), in which case those /proc entries don't exist to be tuned; harmless, safe to ignore for those specific lines.
  • A line reported as an invalid key name usually means a typo crept into the heredoc — open /etc/sysctl.d/99-security.conf directly and check that exact line against what's documented here.
  • Values not surviving a reboot despite sysctl -p working now — confirm the file is actually in /etc/sysctl.d/ (not /etc/sysctl.conf.d/ or similar typo'd path) since only files in the real sysctl.d directory get auto-loaded at boot.

This isn't just theoretical CIS hardening — it mitigates a real 2026 CVE. A kernel ptrace race condition disclosed in May 2026 (CVE-2026-46333, "ssh-keysign-pwn") let unprivileged local users read sensitive files including /etc/shadow and OpenSSH host private keys. kernel.yama.ptrace_scope = 2 in Group 6 above is exactly the mitigation for this class of attack — restricting ptrace to only admin-approved relationships closes the race window a low-privileged local process would otherwise use. Worth knowing when someone asks "why does this one sysctl setting matter so much" — it's not hypothetical. Only set net.ipv4.icmp_echo_ignore_all = 1 (ignore ALL pings, not just broadcast pings) if the scenario doesn't require ping connectivity for grading/functionality — it can break required monitoring, so it's deliberately left out of the groups above; add it manually only if you're sure.

  • Disable core dumps (can leak credentials/hashes from crashed processes):
bash
echo "* hard core 0" | sudo tee -a /etc/security/limits.conf
Expected result:The line echoes back and is appended to the file.
If it fails:
  • This alone doesn't guarantee no core dumps get written — fs.suid_dumpable = 0 from the sysctl Group 6 above is the kernel-level companion setting; both together are the real fix.
  • If a specific application still writes core dumps afterward, check whether it uses its own crash-handling config that bypasses limits.conf (some daemons set their own RLIMIT_CORE internally).
  • Prevent IP spoofing via /etc/host.conf:
bash
grep -q "nospoof on" /etc/host.conf || echo "nospoof on" | sudo tee -a /etc/host.conf
Expected result:No output if the line already existed (the grep -q found it and the || short-circuited); otherwise the new line echoes and gets appended.
If it fails:
  • No real failure mode — this is a defensively-written idempotent command (safe to run more than once, won't create duplicate lines).
  • If /etc/host.conf doesn't exist at all on this distro (some modern systems have deprecated it in favor of nsswitch.conf-only resolution), the tee -a will simply create it fresh with just that one line, which is harmless.
  • Harden temp mount points against binary execution (skip if the scenario requires software installs from /tmp, which some install scripts do — check first):
bash
sudo mount -o remount,noexec,nosuid,nodev /tmp 2>/dev/null
sudo mount -o remount,noexec,nosuid,nodev /var/tmp 2>/dev/null
sudo mount -o remount,noexec,nosuid,nodev /dev/shm 2>/dev/null

echo "tmpfs /dev/shm tmpfs defaults,noexec,nosuid,nodev 0 0" | sudo tee -a /etc/fstab
Expected result:Each mount -o remount is silent on success; confirm with mount | grep -E '/tmp |/var/tmp |/dev/shm ' showing noexec,nosuid,nodev in the options list.
If it fails:
  • "mount: /tmp: must be superuser" — you're missing sudo.
  • More commonly, the remount silently has no lasting effect if /tmp//var/tmp aren't actually separate mount points on this image (many CyberPatriot images have everything on one root partition) — in that case there's no live mount to remount with new options, and only the /etc/fstab line (which specifically targets /dev/shm, a tmpfs that DOES always exist as its own mount) will actually apply; don't assume the /tmp//var/tmp remounts worked just because the command didn't error.
  • If a required install script needs to execute something from /tmp, this setting will break it — that's exactly the tradeoff the checklist bullet above warns about; check first.
  • Disable loading of legacy/uncommon filesystem drivers (rarely needed, common LPE vector):
bash
cat << 'EOF' | sudo tee /etc/modprobe.d/disable-filesystems.conf
install cramfs /bin/true
install freevxfs /bin/true
install jffs2 /bin/true
install hfs /bin/true
install hfsplus /bin/true
install udf /bin/true
EOF
  • Blacklist exotic network protocols rarely used legitimately:
bash
cat << 'EOF' | sudo tee /etc/modprobe.d/disable-protocols.conf
install dccp /bin/true
install sctp /bin/true
install rds /bin/true
install tipc /bin/true
EOF
  • Disable USB mass storage and Firewire/Thunderbolt if the scenario calls for physical-media lockdown:
bash
echo 'install usb-storage /bin/true' | sudo tee -a /etc/modprobe.d/disable-usb-storage.conf
echo "blacklist firewire-core" | sudo tee -a /etc/modprobe.d/firewire.conf
echo "blacklist thunderbolt" | sudo tee -a /etc/modprobe.d/thunderbolt.conf
Expected result (all three module-blacklist blocks):
  • each heredoc/echo writes the new .conf file under /etc/modprobe.d/ and echoes its content back.
  • These only take effect for modules not already loaded — verify a module you meant to block isn't currently active with lsmod | grep <name>.
If it fails:
  • If lsmod shows one of these modules already loaded (common for udf if a USB/optical drive was mounted earlier in the session), the install ... /bin/true block only prevents FUTURE loads — you must also manually unload it: sudo modprobe -r <module> (fails if something's actively using it, in which case leave it loaded and just accept the future-load block, or handle the dependency first).
  • USB mass storage disablement is a real risk during the competition itself if your team needs to plug in a USB drive later (e.g. to transfer a file) — confirm this isn't needed before applying it, since re-enabling requires deleting the conf file and, if the module already unloaded, may need a reboot to fully restore.

Things to try / extra points#

bash
# Restrict kernel module loading entirely once you're done needing to load new ones (irreversible until reboot)
sudo sysctl -w kernel.modules_disabled=1

# List currently loaded modules to eyeball anything unfamiliar before locking module loading
lsmod

# Verify each sysctl value actually took effect
sudo sysctl kernel.yama.ptrace_scope kernel.kptr_restrict fs.suid_dumpable net.ipv4.ip_forward

# CIS Benchmark — Level 2: disable Bluetooth and wireless interfaces if the scenario doesn't need them
# (physical/RF attack surface CIS calls out separately from the network-stack sysctls above)
sudo systemctl disable --now bluetooth 2>/dev/null
rfkill list 2>/dev/null
sudo apt purge -y bluez -y 2>/dev/null      # Debian/Ubuntu — only if Bluetooth is confirmed unneeded
Expected result:
  • sysctl -w kernel.modules_disabled=1 prints the new value back.
  • lsmod lists all currently loaded kernel modules with size/use-count.
  • The sudo sysctl verification line prints all four requested values in one shot.
  • rfkill list shows wireless/Bluetooth radio state (blocked/unblocked).
If it fails:
  • After setting kernel.modules_disabled=1, ANY further modprobe/module-loading command will fail until reboot — this is by design, but it means if you discover you need one more kernel module (e.g. for a driver), you're stuck until a reboot; that's exactly why the doc says apply this one last.
  • rfkill: command not found — install util-linux (usually already present) or skip this specific check if Bluetooth/wireless aren't relevant hardware on this image anyway (many CyberPatriot VMs have none).

This entire sysctl block (IP forwarding, redirects, source routing, rp_filter, SYN cookies, ASLR via kernel.randomize_va_space) matches CIS Benchmark Network Configuration and Kernel Modules guidance almost line-for-line, and the filesystem/protocol module blacklisting further up this section (cramfs, freevxfs, jffs2, hfs, hfsplus, udf, dccp, sctp, rds, tipc) is the exact CIS-named "unnecessary filesystem/protocol kernel modules" list — this is one of the most directly CIS-aligned sections in the whole document already. USB storage disablement further up is also a named CIS control (Level 1 or 2 depending on the profile your team follows).

CIS Level 2 to try if time allows: Bluetooth and wireless interface disablement above, plus net.ipv6.conf.all.disable_ipv6 (already in this section's sysctl block) — both are commonly Level 2 because they can break legitimate functionality on machines that need those interfaces; confirm the README doesn't require them first.

kernel.modules_disabled=1 is a one-way door until reboot — apply it last, after you're confident you won't need modprobe for anything else (e.g., installing a required driver).


19. Screen Lock / Auto-Lock Settings#

  • Enable automatic screen lock after a short idle timeout (desktop environment settings or dconf/gsettings on GNOME-based Mint/Ubuntu):
bash
gsettings set org.gnome.desktop.screensaver lock-enabled true
gsettings set org.gnome.desktop.screensaver lock-delay 300
gsettings set org.gnome.desktop.session idle-delay 300
  • Cinnamon (Linux Mint default DE):
bash
gsettings set org.cinnamon.desktop.screensaver lock-enabled true
gsettings set org.cinnamon.desktop.session idle-delay 300
Expected result (both GNOME and Cinnamon blocks):
  • silent on success.
  • Verify with the matching gsettings get (e.g. gsettings get org.gnome.desktop.screensaver lock-enabled should print true).
If it fails:
  • "No such schema" means you're running the WRONG desktop environment's commands — GNOME schemas don't exist under Cinnamon and vice versa; check which DE is actually running (echo $XDG_CURRENT_DESKTOP or echo $DESKTOP_SESSION) and use the matching block.
  • gsettings also only affects the currently logged-in graphical user's settings, not system-wide defaults — if the scoring check expects this for a different user account, you need to run it from within that user's own graphical session (or set it via dconf system-wide defaults, see the automount-lock example further down this section for that pattern).
  • On a headless/no-GUI server image, none of this applies at all — skip the section.
  • Disable automatic login at the display manager (LightDM):
bash
sudo sed -i 's/^autologin-user=.*/#autologin-user=/' /etc/lightdm/lightdm.conf
Expected result:Silent on success; grep autologin /etc/lightdm/lightdm.conf afterward shows the line commented out.
If it fails:
  • "No such file or directory" means this system isn't using LightDM (GDM or SDDM instead, or no display manager at all on a server image) — check for autologin config in the right place instead: GDM uses /etc/gdm3/custom.conf with an [daemon] section's AutomaticLoginEnable=/AutomaticLogin= keys.
  • If the sed runs but autologin still happens on next boot, the setting may be duplicated in a .conf snippet under /etc/lightdm/lightdm.conf.d/ that's read after (and overrides) the main file — check there too.

Things to try / extra points#

bash
# Verify no autologin is configured anywhere
grep -ri "autologin" /etc/lightdm/lightdm.conf /etc/gdm3/custom.conf 2>/dev/null

# CIS Benchmark — GNOME Display Manager: disable automatic mounting of removable media at the login screen
# (a common bypass for physical-access attacks CIS calls out specifically under GDM configuration)
sudo mkdir -p /etc/dconf/db/local.d /etc/dconf/db/local.d/locks
cat << 'EOF' | sudo tee /etc/dconf/db/local.d/00-media-automount
[org/gnome/desktop/media-handling]
automount=false
automount-open=false
EOF
echo "/org/gnome/desktop/media-handling/automount" | sudo tee -a /etc/dconf/db/local.d/locks/media-automount-lock
sudo dconf update

# CIS — ensure XDMCP (remote graphical login) is not enabled — legacy protocol, sends credentials in the clear
grep -i "^Enable=true" /etc/gdm3/custom.conf 2>/dev/null
Expected result:
  • The autologin grep prints nothing if clean.
  • The dconf/media-automount block writes two files and a lock entry, then dconf update compiles the database silently.
  • The XDMCP grep should print nothing (XDMCP section absent or Enable=false).
If it fails:
  • dconf update erroring about a malformed .d file usually means a typo in the heredoc's [section]/key=value syntax — dconf keyfile format is strict about the bracket-header line matching a real schema path exactly as written above.
  • If automount still happens after this, confirm you're checking the SAME desktop environment (this dconf lock targets GNOME's media-handling schema specifically — it does nothing under Cinnamon/MATE, which have their own equivalent settings under a different schema path).
  • An XDMCP hit (Enable=true found) is a real, fixable finding — set it to Enable=false under the [xdmcp] section of /etc/gdm3/custom.conf and restart the display manager.

The idle-lock and autologin checks above are CIS Benchmark GNOME Display Manager controls where GNOME/Cinnamon is in use. The automount and XDMCP items are the same control family but less commonly checked — automount in particular matters because CyberPatriot images are sometimes graded on whether inserting removable media at the lock screen would auto-run anything.

GUI path (Mint/Ubuntu): Settings → Screensaver / Privacy → Screen Lock, and Settings → Login Window for autologin — good for a quick visual confirmation instead of hunting config files.


20. Mandatory Access Control (AppArmor/SELinux)#

  • Debian/Ubuntu/Mint use AppArmor by default; RHEL/Fedora/CentOS use SELinux.

AppArmor:

bash
sudo aa-status
sudo systemctl enable --now apparmor
sudo aa-enforce /etc/apparmor.d/*
Expected result:
  • aa-status prints counts of profiles loaded, in enforce mode, and in complain mode, plus which processes are unconfined.
  • aa-enforce on the whole directory switches every loadable profile to enforce mode, printing one confirmation line per profile.
If it fails:
  • "apparmor_parser: ...
  • Permission denied" or similar on individual profiles inside /etc/apparmor.d/* doesn't stop the rest from applying — check which specific ones failed in the output and investigate those individually rather than assuming the whole operation failed.
  • aa-enforce: command not found means the apparmor-utils package (separate from base apparmor) isn't installed — sudo apt install apparmor-utils -y first.
  • Identify network-listening processes running unconfined (no active MAC profile):
bash
sudo aa-unconfined --active
Expected result:A list of network-listening processes and whether each has an AppArmor profile loaded — not confined next to a process name is the finding to investigate.
If it fails:
  • aa-unconfined: command not found — same apparmor-utils dependency as aa-enforce above.
  • Most processes on a stock system will legitimately show as unconfined (AppArmor ships profiles for relatively few applications by default) — this isn't automatically a vulnerability, just something worth being aware of; only chase it further if the README specifically calls out MAC coverage as a requirement.

SELinux:

bash
sestatus
sudo setenforce 1                     # force Enforcing mode for current boot
sudo sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config   # persist across reboots
Expected result:
  • sestatus prints SELinux status: enabled and Current mode: enforcing after the setenforce.
  • The sed line makes it survive a reboot too.
If it fails:
  • setenforce: SELinux is disabled means SELinux isn't just in permissive mode, it's fully off at the kernel level — that requires a selinux=0/enforcing=0 boot parameter to be removed from GRUB (see the CIS bootloader check right below this block) AND a reboot to take effect; setenforce alone can't turn on something that's disabled at boot.
  • If flipping to enforcing breaks a required service immediately, check sudo ausearch -m avc -ts recent (shown in Things to try) to see exactly which SELinux policy is blocking it before deciding whether to fix the policy or accept complain mode as a fallback.

Things to try / extra points#

bash
# List any AppArmor profiles running in complain (non-enforcing) mode
sudo aa-status | grep -A5 "in complain mode"

# SELinux: check recent denials that might indicate something is misconfigured (not necessarily malicious)
sudo ausearch -m avc -ts recent

# CIS Benchmark — Mandatory Access Control: confirm MAC isn't disabled at the bootloader level
# (enforcing mode means nothing if GRUB passes apparmor=0 / selinux=0 at boot)
grep -E "apparmor=0|selinux=0" /etc/default/grub /boot/grub/grub.cfg /boot/grub2/grub.cfg 2>/dev/null

# CIS — SELinux systems: troubleshooting/translation packages should generally be removed on a hardened box
# (they add attack surface and aren't needed once you're done debugging denials)
rpm -q setroubleshoot mcstrans 2>/dev/null
sudo dnf remove -y setroubleshoot mcstrans 2>/dev/null
Expected result:
  • The complain-mode grep prints any AppArmor profiles NOT enforcing.
  • ausearch -m avc -ts recent prints recent SELinux denial records if any exist (empty is fine — means nothing's been denied recently, not that SELinux is broken).
  • The GRUB grep should print nothing (no boot-time MAC-disabling parameter present).
  • rpm -q shows whether the troubleshooting packages are installed at all before trying to remove them.
If it fails:
  • ausearch: command not found needs the audit/auditd package (see Section 15) — install it first if you need this specific check.
  • A hit on the GRUB parameter check (selinux=0 or apparmor=0 found) is a real, high-priority finding: edit /etc/default/grub's GRUB_CMDLINE_LINUX line to remove the offending parameter, then regenerate the boot config (sudo grub2-mkconfig -o /boot/grub2/grub.cfg or sudo update-grub on Debian/Ubuntu) and reboot — a bootloader-level disable completely defeats any setenforce/config-file change you make while the system is running.
  • rpm -q on a Debian/Ubuntu box (no rpm command) — this whole SELinux sub-block only applies to the RHEL/Fedora family; skip it if you're on Debian/Ubuntu/Mint (which use AppArmor instead, covered above).

Verifying AppArmor/SELinux is installed, not disabled at the bootloader, and in enforcing mode is exactly CIS Benchmark Mandatory Access Control guidance — this is a named Level 1 control group, and the GRUB-parameter check above is the part people most often forget since aa-status/sestatus alone won't catch a bootloader override.

If the scenario doesn't mention MAC at all, still verify it's enabled and enforcing rather than disabled — a disabled AppArmor/SELinux is itself frequently a scored finding.


21. Advanced / Niche Rootkit & Persistence Hunting#

Use these once the basics above are done and you're hunting for planted, harder-to-find issues.

  • Regular files hidden inside /dev (device directory should only contain device nodes):
bash
sudo find /dev -type f
Expected result:Normally empty — /dev should contain only device nodes (character/block special files), sockets, and symlinks, never regular files.
If it fails:No error mode; ANY output here is itself a real finding worth investigating directly (a regular file hiding in /dev is a classic rootkit stash location, since most admins never think to look there).
  • Hidden/rogue processes — compare /proc PIDs against what ps reports (rootkits sometimes hide PIDs from ps/top but can't hide the /proc entry):
bash
comm -23 <(ls -d /proc/[0-9]* | awk -F/ '{print $3}' | sort -n) <(ps -A -o pid= | awk '{print $1}' | sort -n)
Expected result:Normally empty — every PID directory under /proc should also show up in ps output.
If it fails:
  • A PID appearing here that ps doesn't show is a strong hidden-process indicator, but first rule out a mundane cause: a process that exited in the split-second between the two commands running (a race condition, not a rootkit) can cause a spurious one-off hit — re-run the command a couple of times; a PID that shows up as "hidden" consistently across repeated runs is the real signal.
  • comm: command not found (very rare, it's part of core coreutils) — if truly missing, compare the two lists manually with diff <(...) <(...) instead.
  • Systemd drop-in overrides — malware can persist by adding an override.conf to a legitimate service's systemd directory:
bash
sudo find /etc/systemd/system/ -name "*.conf" -o -name "*.service"
Expected result:A list of custom unit files/overrides living outside the normal package-managed /lib/systemd/system/ or /usr/lib/systemd/system/ locations — some of these are legitimate (locally-created services), some may not be.
If it fails:No error mode; cross-check anything unfamiliar against what the README says should be running, and open suspicious ones directly (cat the file) to see what command/binary they actually execute — an override pointing at a script in /tmp or a user's home directory is a strong red flag.
  • PAM shared-object integrity — confirm every .so referenced in /etc/pam.d/* resolves to a legitimate system library path:
bash
grep -vE '^#|^$' /etc/pam.d/* | grep -v 'pam_'
grep -r "so" /etc/pam.d/ | grep -v "/lib/" | grep -v "/usr/lib/"
Expected result:
  • The first line filters out comments/blanks/pam_-named-module lines, so it should print very little on a normal system — mostly this surfaces any oddly-named non-standard module.
  • The second should print nothing (already covered identically in Section 14 — this is the same check repeated for the deeper-hunting pass).
If it fails:Same guidance as Section 14's identical check — any .so path that lands outside /lib/ or /usr/lib/ deserves a direct look at that specific pam.d file and the actual library file it points to.
  • What's holding a given port open — resolve PID → binary path directly (helper one-liner using fuser):
bash
for p in $(sudo fuser <port>/tcp 2>/dev/null); do readlink -f /proc/$p/exe; done
Expected result:The full filesystem path of the binary bound to that port (e.g. /usr/sbin/sshd).
If it fails:
  • fuser: command not found — it's part of psmisc, install with sudo apt install psmisc -y (Debian/Ubuntu) or use ss -tulpn | grep :<port> (already covered in Section 7) as a fuser-free equivalent that gives PID + process name directly.
  • No output at all means nothing is currently listening on that port — double check the port number and that you're testing TCP vs UDP correctly.

Things to try / extra points#

bash
# Kernel modules worth a manual once-over for anything unfamiliar
lsmod | sort

# Check for LD_PRELOAD set in any global shell/env config, not just /etc/ld.so.preload
grep -r "LD_PRELOAD" /etc/environment /etc/profile* /home/*/.bashrc /root/.bashrc 2>/dev/null
Expected result:
  • lsmod | sort prints every loaded kernel module alphabetically for easier eyeballing.
  • The LD_PRELOAD grep should print nothing on a clean system.
If it fails:
  • No error mode for either.
  • An unfamiliar module name in lsmod isn't automatically malicious — many are legitimate hardware/filesystem drivers; cross-check an unfamiliar name with modinfo <name> (shows the module's description and source) before treating it as suspicious.
  • Any LD_PRELOAD hit is worth investigating directly — it forces a specific shared library into whichever process's environment it's set for, the same rootkit technique as /etc/ld.so.preload but scoped to one shell/user instead of system-wide.

These checks are higher-effort/lower-frequency findings — prioritize the core sections (users, SSH, firewall, updates, malware scan) first since they cover the vast majority of scored items, then spend remaining time here.

Note on CIS scope: unlike most other sections in this document, the checks in this section (LD_PRELOAD hijacking, PAM .so injection, hidden /dev files, PID-hiding rootkit detection) are not part of the standard CIS Benchmark baseline — they're general incident-response/forensics technique, useful for CyberPatriot's planted-malware scenarios but not something a CIS audit tool would flag. Treat Sections 1-20 as your CIS-aligned baseline and this section as bonus hunting once that baseline is solid.


22. Server Configuration Notes (Apache/Samba/MySQL/etc.)#

If the README explicitly requires a web server, database, or file-sharing service to remain running, harden it rather than removing it.

This is a commonly under-scored area: points here very often come from the actual configuration content of a required service, not just whether it's enabled/running. A scoring engine checking "is Apache installed and running" gives you nothing for the fact that directory listing is wide open, or that MySQL still has an anonymous/no-password root account. If your README requires a service, don't stop at "service is up" — open its config file and read it.

Apache (/etc/apache2/apache2.conf or /etc/httpd/conf/httpd.conf):

shell
TraceEnable off                 # prevents cookie-theft via TRACE method
ServerSignature Off
ServerTokens Prod
User apache                     # never run Apache's worker as root
Group apache

Inside the relevant <Directory> block for the web root:

shell
Options -Indexes -FollowSymLinks -Includes -ExecCGI
  • Restart Apache after config changes and test syntax first:
bash
sudo apache2ctl configtest      # Debian/Ubuntu
sudo apachectl configtest       # RHEL/Fedora
sudo systemctl restart apache2  # Debian/Ubuntu
sudo systemctl restart httpd    # RHEL/Fedora
Expected result:
  • configtest prints Syntax OK.
  • The restart is silent on success; confirm with systemctl status apache2/httpd showing active (running).
If it fails:
  • Any syntax error message from configtest names the exact file and line — fix that before restarting, since restarting with a bad config will either fail outright or (worse, depending on the distro's service script) leave the OLD process running while looking like it "should" have picked up your change.
  • Wrong unit/binary name for this distro (apache2 is Debian/Ubuntu's name, httpd is RHEL/Fedora's) — using the wrong one gives "Unit not found."
  • Samba: if required, restrict to known shares/users only and disable guest access in smb.conf; if not required, purge it entirely (see Section 10).
bash
grep -E "^\s*(guest ok|browseable|security)" /etc/samba/smb.conf
# guest ok should be "no" on any share that shouldn't be publicly accessible; security should not be "share"
Expected result:Prints matching directive lines from every share stanza in the file.
If it fails:
  • No output at all can mean these directives are simply absent (Samba falls back to its compiled-in defaults, which vary by version — don't assume absence means "safe," check testparm -s for the actually-effective values instead) rather than the file having no shares configured.
  • After editing smb.conf, always run sudo testparm before restarting — like apache2ctl configtest, it validates syntax and will point out the exact problem line if something's malformed.

MySQL / MariaDB (/etc/mysql/my.cnf, /etc/mysql/mysql.conf.d/, or /etc/my.cnf on RHEL):

If a database is required by the README, these are the config-content checks that most commonly hide points:

  • Remove anonymous database users — a stock/insecure MySQL install often ships with a blank-username account anyone can connect as:
bash
sudo mysql -u root -p -e "SELECT user, host FROM mysql.user WHERE user='';"
sudo mysql -u root -p -e "DROP USER ''@'localhost'; DROP USER ''@'$(hostname)';"
Expected result:
  • The SELECT prints any anonymous-user rows found (empty result set = already clean).
  • The DROP USER statements are silent on success (or print Query OK).
If it fails:"Access denied for user 'root'@'localhost'" means you either don't know the root DB password (different from the OS root password — check if the README lists it, or try no password / blank at first on an unhardened image) or root auth is set to unix_socket/auth_socket plugin instead of a password, in which case run the command as the Linux root user (sudo mysql with no -p, no password prompt) rather than mysql -u root -p. "Error 1396: Operation DROP USER failed" for a specific host value means that exact ''@'host' combination doesn't exist — re-run the SELECT first and use the EXACT host values it returns rather than guessing $(hostname).
  • Confirm root cannot log in remotely (only localhost/127.0.0.1) and has an actual password set:
bash
sudo mysql -u root -p -e "SELECT user, host FROM mysql.user WHERE user='root';"
sudo mysql -u root -p -e "SELECT user, host FROM mysql.user WHERE user='root' AND host NOT IN ('localhost','127.0.0.1');"
# drop or reassign any row where host isn't localhost/127.0.0.1
Expected result:
  • The first query lists every root row (usually just root@localhost).
  • The second should return an empty result — any row it does return is a remote-root-login finding.
If it fails:
  • Same auth caveats as the anonymous-user block above apply here.
  • If the second query does return a row, don't just DROP it blindly if you're not sure it's unauthorized — check the README first in case remote DB administration is actually part of the required scenario; if it's genuinely not needed, DROP USER 'root'@'<that host value>'; removes it.
  • Restrict network exposure if remote database access isn't explicitly required — either bind to localhost only, or disable networking entirely:
bash
grep -E "^(bind-address|skip-networking)" /etc/mysql/my.cnf /etc/mysql/mysql.conf.d/*.cnf 2>/dev/null
# add/confirm:  bind-address = 127.0.0.1
# or, if the DB is only ever accessed locally:  skip-networking
Expected result:Prints the current setting if present; absence of any bind-address line often means the default is 0.0.0.0 (all interfaces) on many MySQL/MariaDB builds — worth setting explicitly rather than assuming.
If it fails:If a required application needs to reach this database FROM A DIFFERENT HOST, bind-address = 127.0.0.1 or skip-networking will break it — confirm against the README before applying; if remote access genuinely is required, restrict via firewall rules to specific source IPs instead (see Section 6) rather than blocking all network access to the DB.
  • Run the built-in hardening helper, which walks through most of the above interactively:
bash
sudo mysql_secure_installation
Expected result:An interactive series of yes/no prompts (set root password, remove anonymous users, disallow remote root login, remove test database, reload privileges) — answer "yes" to each hardening prompt unless the README specifically needs one of them left alone (e.g. a required test database).
If it fails:
  • "command not found" means this is a very minimal install missing the helper script — the manual SQL commands above achieve the same result piece by piece.
  • If it hangs waiting for input in a non-interactive context, make sure you're running it from a real terminal session, not through a script/pipe.
  • Restart and re-verify after any config change:
bash
sudo systemctl restart mysql       # Debian/Ubuntu
sudo systemctl restart mariadb     # RHEL/Fedora / some Debian variants
Expected result:Silent on success; systemctl status afterward shows active (running).
If it fails:
  • "Unit not found" — try the other name (mysql vs mariadb vs mysqld depending on distro/fork); systemctl list-units | grep -i sql shows the real unit name on this box.
  • A restart that fails immediately after a config edit almost always means a syntax error in my.cnf — check sudo journalctl -u mysql -n 50 (or the mariadb equivalent) for the specific parse error.

Things to try / extra points#

bash
# Verify Apache isn't running as root
ps aux | grep apache2

# Check for directory listing exposure live
curl -s http://localhost/ | grep -i "Index of"

# Verify the config file actually parses/loads cleanly after edits — a syntax error can leave the
# OLD config running (service stays "up" but your fix silently didn't apply) or block a restart entirely
sudo apache2ctl configtest 2>&1 | tail -5
sudo mysqld --validate-config 2>&1 | tail -5   # or: sudo mysqld_safe --help --verbose | grep -A1 "^my.cnf"

# GUI alternative (Mint/Ubuntu): phpMyAdmin or a plain `mysql` client are more reliable than hunting
# for a GUI DB tool during a round — if one's already installed (don't install new software mid-round
# unless needed), it can speed up checking users/hosts visually instead of writing SQL by hand.

# Fallback if Apache isn't the web server in use: nginx keeps the equivalent settings in
# /etc/nginx/nginx.conf and per-site files under /etc/nginx/sites-enabled/ — look for `autoindex on;`
# (nginx's equivalent of Apache's directory listing) and `server_tokens` (version disclosure)
grep -rn "autoindex\|server_tokens" /etc/nginx/ 2>/dev/null

# Edge case: a required service can be configured correctly in its main config file but overridden by
# a per-site/per-vhost config that re-enables what you just turned off — check those too
ls /etc/apache2/sites-enabled/ /etc/apache2/conf-enabled/ 2>/dev/null
grep -rn "Indexes\|ServerSignature" /etc/apache2/sites-enabled/ 2>/dev/null
Expected result:
  • ps aux | grep apache2 shows worker processes running as the www-data/apache user, NOT root (one root-owned master process managing them is normal — that's Apache's standard privilege-drop pattern, not a vulnerability).
  • The curl directory-listing check prints matching lines only if listing is actually exposed live.
  • The configtest/mysqld --validate-config re-checks confirm your edits parsed cleanly.
  • The nginx grep and vhost-override checks print any matching lines found in those files.
If it fails:
  • Seeing MULTIPLE processes all running as root (not just one master) means User apache/Group apache isn't actually taking effect — recheck you edited the config Apache is actually loading (apache2ctl -S shows the active config file path) and restarted after the edit.
  • The curl check returning nothing doesn't guarantee listing is off if the URL you tested isn't actually the directory in question — test the specific path the README/scenario cares about, not just /.
  • A vhost-level override winning over your main-config fix is a real, easy-to-miss failure mode — if a setting doesn't seem to be taking effect despite editing the "right" file, always check sites-enabled/conf-enabled for a conflicting later-loaded directive.

The Apache directives above (TraceEnable off, ServerSignature Off, ServerTokens Prod, Options -Indexes) and the MySQL checks (anonymous users, remote root, network exposure) are the two most common "service is running but misconfigured" patterns in past CyberPatriot Linux images — treat "service enabled + correctly configured" as the actual target, not just "service enabled."


23. Quick-Reference Command Cheat Sheet#

A condensed pass for when you're low on time — run down this list top to bottom.

bash
# --- Recon ---
cat /etc/os-release; uname -a

# --- Users/Groups ---
awk -F: '($3 == 0) {print}' /etc/passwd                 # non-root UID 0
awk -F: '($2 == "") {print $1}' /etc/shadow              # empty passwords (needs sudo)
getent group sudo; getent group wheel                    # who has admin rights
sudo cat /etc/sudoers; sudo ls -la /etc/sudoers.d/

# --- Password policy ---
sudo apt install libpam-pwquality -y  # or: sudo dnf install pam_pwquality -y

# --- SSH ---
sudo sshd -t && sudo systemctl restart sshd

# --- Firewall ---
sudo ufw default deny incoming && sudo ufw enable        # or firewall-cmd equivalents

# --- Services/Ports ---
sudo ss -tulpn
systemctl list-unit-files --type=service --state=enabled

# --- Updates ---
sudo apt update && sudo apt upgrade -y                    # or dnf/yum update -y

# --- Malware ---
sudo rkhunter --check; sudo chkrootkit; sudo clamscan -r /home

# --- Prohibited software/media ---
sudo find /home -iname "*.mp3" -o -iname "*.mp4" 2>/dev/null
which nmap wireshark netcat john hydra 2>/dev/null

# --- Permissions ---
sudo find / -xdev -perm -4000 -o -perm -2000 2>/dev/null
sudo find / -xdev -type f -perm -0002 -ls 2>/dev/null

# --- Cron ---
sudo crontab -l; cat /etc/crontab; systemctl list-timers --all

# --- Logs ---
sudo grep "Failed password" /var/log/auth.log            # or /var/log/secure
What this awk command does
Same colon-splitting trick as above, but this time it's a filter: ($3 == 0) means "only print lines where field 3 (the UID) equals 0." UID 0 = root-level privileges, so this finds every account that has full root power — there should normally only be one (root itself).

Note on this section specifically: every command above is a condensed repeat of something already fully documented earlier in this file, deliberately kept terse here since the whole point of a cheat sheet is speed, not re-explaining each line. If any of these come back unexpected or error out, jump to that topic's full section above (Users & Groups → Section 3, SSH → Section 5, Firewall → Section 6, etc.) for the complete expected-result/troubleshooting notes rather than duplicating them here.

Snapshot reminder: once the system is stable and scoring well, take a fresh snapshot/checkpoint labeled pre-submission. If something breaks in the final minutes, you can roll back to this known-good state instead of scrambling to fix it live.

Final pass before submitting: re-check the README one more time — confirm every required user exists and can log in, every required service is running and reachable, and nothing you "fixed" broke the scenario's stated functionality.