linux Linux Master Checklist
Full Debian/Ubuntu + RHEL/Fedora hardening checklist — accounts, SSH, firewall, malware hunting, and more.
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:
- Read the README on the Desktop FIRST. It defines the scenario, required users, required services, and forbidden items. Nothing in this document overrides it.
- Answer forensics questions BEFORE hardening. Many hardening steps (rotating logs, restarting services, deleting files, locking accounts) destroy the evidence you need to answer them.
- 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.
- Snapshot before you change things. Keep a running text file of every command you run so you can backtrack if something breaks.
- Reboot networking/SSH changes carefully. Test config syntax (
sshd -t) before restarting the SSH service — don't lock yourself out.
Table of Contents#
- Initial Recon & README
- Forensics Questions Strategy
- User & Group Auditing
- Password Policy (PAM, login.defs)
- SSH Hardening
- Firewall (ufw / iptables / firewalld)
- Services & Open Ports Audit
- Package Updates
- Malware & Rootkit Scanning
- Prohibited/Unauthorized Software & Media
- File Permissions & SUID/SGID Audits
- Cron Jobs & Scheduled Tasks Audit
- World-Writable Files & Unowned Files
- Bash History, Hidden Files & Shell Profile Review
- Log Review
- Auto-Updates Configuration
- GUI-Based Tool Notes (Mint/Ubuntu)
- Kernel & Sysctl Hardening
- Screen Lock / Auto-Lock Settings
- Mandatory Access Control (AppArmor/SELinux)
- Advanced / Niche Rootkit & Persistence Hunting
- Server Configuration Notes (Apache/Samba/etc.)
- Quick-Reference Command Cheat Sheet
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:
cat /etc/os-release
lsb_release -a 2>/dev/null
uname -a
/etc/os-releaseprintsNAME=,VERSION=, andID=lines (e.g.ID=ubuntuorID=debianorID=centos) — this is the most reliable source.lsb_release -aprintsDistributor ID:,Description:,Release:,Codename:.uname -aprints one line with kernel name, hostname, kernel version, and architecture (e.g.x86_64).
lsb_release: command not foundis common on minimal Debian/CentOS/Fedora installs because thelsb-release(Debian/Ubuntu) orredhat-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_versiondirectly.- If
/etc/os-releaseitself is missing (very old or stripped-down image), fall back tocat /etc/*-releaseoruname -aalone 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#
# 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
hostnamectlprints hostname, machine ID, OS, kernel, and architecture in a labeled block.who -a/last -aprint login session tables (username, tty, time, and forlast, duration/still-logged-in status).mount/lsblkprint current mount points and the block-device/partition tree.
hostnamectl: command not foundmeans systemd isn't in use (rare, but possible on a minimal/older or non-systemd distro) — fall back to plainhostname+uname -a.last -ashowing "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
lsblkisn't installed (very stripped-down image),fdisk -l(as root) orcat /proc/partitionsare near-equivalents.
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
df -hprints a table of mounted filesystems with human-readable sizes/usage percentages.cat /etc/*-releaseprints one or more distro-identifying files.timedatectlprints local time, timezone, and NTP sync status;date(its fallback) just prints the current date/time on one line.hostname/hostname -Iprint the machine's name and its IP address(es).
timedatectlmissing (command not found) confirms non-systemd or a very old distro —datealone is the reliable fallback and is always present.hostname -Iprinting nothing usually means no interface has an IP yet (networking not up) — check withip ainstead to see interface state directly.- If the system clock looks wrong, fix it (
sudo timedatectl set-time "YYYY-MM-DD HH:MM:SS"orsudo 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-releasedoes. Save abefore.txtsnapshot ofdpkg -l/rpm -qa,ss -tulpn, and/etc/passwdright 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 whichnodev/nosuid/noexecmount 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-hardeningso 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, andfileto answer without modifying evidence.
Things to try / extra points#
# 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.
md5sum/sha256sumprint a hex hash followed by the filename.find -newermtprints matching file paths (empty output = nothing matched that window, not necessarily an error).statprints a labeled block (Access/Modify/Change times, UID/GID, permissions).fileprints a one-line type guess (e.g.ELF 64-bit LSB executableorASCII text) regardless of the file's extension.stringsstreams readable text fragments throughlessfor paging.
- "Permission denied" scattered throughout
find/statoutput when NOT run as root/sudo on protected paths — expected noise, not a real problem, since the command still completes; addsudoif you specifically need to search root-owned areas like/root. stringswith no useful output on a text file just means there's nothing binary to extract — open it directly withcat/lessinstead.- If
filereports "cannot open" for a path you're sure exists, double-check for a typo or a broken symlink (ls -laon the path will show-> targetand whether the target exists).
Verification: after answering a forensics question, re-run the same
find/grepyou 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 |
# 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()))"
base64: invalid inputmeans 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 thevim-common(Debian/Ubuntu) orvim-enhanced/xxd(RHEL/Fedora) package; use thepython3hex-decode fallback shown right below it instead, which needs nothing but a stock Python 3 install.- If
troutput 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_historyentries, cron job comments, a file's extended attributes (getfattr -d file), and image metadata (exiftool file.jpgif installed, orfile file.jpgat 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.jpgorzsteg file.pngif 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:
cat /etc/passwd | awk -F: '{print $1, $3, $7}'
-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):
awk -F: '($3 == 0) {print}' /etc/passwd
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:
awk -F: '$3 > 999 && $3 < 65534 {print $1}' /etc/passwd
- Check for accounts that shouldn't have a login shell (service accounts) but do:
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) orwheel(RHEL/Fedora):
getent group sudo
getent group wheel
cat /etc/group
- Audit
/etc/sudoersand/etc/sudoers.d/for unauthorized privilege grants:
sudo cat /etc/sudoers
sudo ls -la /etc/sudoers.d/
sudo visudo -c # validate syntax before saving any edits
getent group sudo/wheelprints a line likesudo:x:27:alice,bob(empty membership after the last:means nobody's in it — normal on some images).visudo -cprints/etc/sudoers: parsed OKon success.
getent group wheelreturning nothing on a Debian/Ubuntu box is expected — that distro family usessudo, notwheel; check whichever group name matches the actual distro family from Section 1. "sudo: a password is required" onsudo cat /etc/sudoersmeans 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/sudoersedit without runningvisudo -c(or editing throughvisudoitself in the first place) — a syntax error in this file can lock outsudofor 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):
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):
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:
sudo userdel -r <username> # -r also removes home dir — use with caution
deluser/gpasswd -dprint a one-line confirmation (e.g.Removing user 'x' from group 'sudo'...).passwd -lprintspasswd: password expiry information changed.and prefixes the shadow-file hash with!(locked).usermod -Lis silent on success.userdel -ris silent on success and removes the home directory.
- "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 -Lare trivially reversible (passwd -u/usermod -U),userdel -ris 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
sudowhere required):
sudo passwd -l root
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.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):
echo "allow-guest=false" | sudo tee -a /etc/lightdm/lightdm.conf
cat /etc/lightdm/lightdm.conf); the guest session option disappears from the LightDM login screen after a reboot or sudo systemctl restart lightdm./etc/lightdm/lightdm.conf: No such file or directorymeans 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#
# 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
-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.
- 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/shadowshould show exactly-rw-r--r--(or-rw-r--r--.with SELinux context) for passwd and-rw-r-----for shadow, both owned by root.chage -lprints a labeled block of password-aging dates for that one user.passwd -Sprints<username> <status> ...where status isL,P, orNPas commented in the file.
- 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/shadowpermissions show anything more permissive than640 root:shadow(e.g. world-readable), that's itself a serious, commonly-scored vulnerability — fix withsudo 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 -lbeing much larger thanwc -l /etc/passwdconfirms 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/passwdediting 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
nologinas the shell for all system/service accounts that don't need one (CIS "ensure system accounts are secured"), and confirming/etc/shellsdoesn't listnologinas 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 -Llocks the password but leaves the account and files intact — always safer during a competition thanuserdel, 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:
- Maximum password age — forces a password change at least this often:
sudo sed -i 's/^PASS_MAX_DAYS.*/PASS_MAX_DAYS 90/' /etc/login.defs
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.
- Minimum password age — stops a user from immediately changing back to their old password after being forced to rotate:
sudo sed -i 's/^PASS_MIN_DAYS.*/PASS_MIN_DAYS 10/' /etc/login.defs
- Minimum password length — rejects short passwords at creation time (belt-and-suspenders alongside
pam_pwqualitybelow):
sudo sed -i 's/^PASS_MIN_LEN.*/PASS_MIN_LEN 8/' /etc/login.defs
- Warning age — gives users a heads-up before their password expires, so it doesn't lock them out mid-round:
sudo sed -i 's/^PASS_WARN_AGE.*/PASS_WARN_AGE 7/' /etc/login.defs
- Verify all four landed:
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_DAYSto existing users too —login.defsonly affects newly created accounts, so without this step every account that already existed on the image keeps its old (often "never expires") aging settings:
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:
for u in $(awk -F: '$3 > 999 && $3 < 65534 {print $1}' /etc/passwd); do sudo chage --maxdays 90 --mindays 10 --warndays 7 "$u"; done
- 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.
chage: user '<username>' does not existis a typo — recheck the exact name withcut -d: -f1 /etc/passwd.- The loop version silently skips any account whose
chagecall 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-runchageon 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 |
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
Setting up libpam-pwquality ... / Complete!.- "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 pwqualityorrpm -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-authand/etc/pam.d/password-auth(RHEL family) to enforce complexity:
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
- 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 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 usesystem-auth/password-auth— editing the wrong one silently does nothing) or a duplicate/conflictingpasswordline elsewhere earlier in the same file that PAM evaluates first and short-circuits on. - Also confirm the line uses
requisite(stops immediately on failure) notoptional— 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 timeauthselectregenerates its profile — checkauthselect currentfirst.
-
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 (needscracklib-runtime/cracklib-dictsinstalled, usually a dependency of the pwquality package already). - Enforce password history (prevent reuse) via
pam_unix.so:
password [success=1 default=ignore] pam_unix.so obscure use_authtok try_first_pass sha512 remember=5
- 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.
- History enforcement not working usually means
pam_pwquality's line above it isn't set torequisitecorrectly, or this line's position relative to thepam_pwqualityline in the file is wrong —pam_unix.soneeds to come AFTER the quality check line, not before, since PAM processes password-stack lines top to bottom. - Also check
/etc/security/opasswdexists and is writable (root-only) — that's wherepam_unixstores password history hashes to compare future changes against; if it doesn't exist,sudo touch /etc/security/opasswd && sudo chmod 600 /etc/security/opasswdcreates it.
- Enforce account lockout after failed attempts. Older systems use
pam_tally2; modern systems usepam_faillock.
# 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
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.
- The
tee -aline prints the appended line back to the terminal (that'stee'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_tally2check commands right below in "Things to try").
- "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_faillockblock 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#
# 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
faillock/pam_tally2 --userprint the account's current failed-attempt count and lock state.- The
pwquality.confcat and grep print the active complexity settings. - The
TMOUTlines silently create a new profile script that takes effect on next login. useradd -D | grep INACTIVEprints the current default (often-1, meaning disabled).- The test-account block should show
passwdrejecting the weak password with a message like "BAD PASSWORD: it is too simplistic/systematic" ifpwqualityis actually working. authselect currenteither names the active profile or prints "No existing configuration was found" if authselect isn't in use on this system.
- 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; runsudo authselect currentto see if it's managing the file, and if so usesudo authselect enable-feature <feature>or a custom profile instead of hand-editing (hand edits under authselect management get wiped on the nextauthselect apply-changes). - Remember to actually run
sudo userdel -r testuser123afterward — don't leave a stray test account on the scored image. TMOUTnot 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_pwqualitycomplexity enforcement, andremember=5history 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 enforcingenforce_for_rootonpam_pwqualityso 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
sudoand 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:
- Block direct root login over SSH (the single highest-value line in this whole section):
sudo sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
- 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):
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
- Block blank-password accounts from logging in over SSH, independent of the setting above:
sudo sed -i 's/^#\?PermitEmptyPasswords.*/PermitEmptyPasswords no/' /etc/ssh/sshd_config
- Turn off X11 forwarding — closes off a GUI-app tunneling attack surface most scenarios don't need:
sudo sed -i 's/^#\?X11Forwarding.*/X11Forwarding no/' /etc/ssh/sshd_config
- Cap authentication attempts per connection — slows down online brute-force guessing:
sudo sed -i 's/^#\?MaxAuthTries.*/MaxAuthTries 3/' /etc/ssh/sshd_config
- 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):
sudo sed -i 's/^#\?ClientAliveInterval.*/ClientAliveInterval 300/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?ClientAliveCountMax.*/ClientAliveCountMax 0/' /etc/ssh/sshd_config
- Verify all six directives actually landed before moving on:
grep -E "^(PermitRootLogin|PasswordAuthentication|PermitEmptyPasswords|X11Forwarding|MaxAuthTries|ClientAliveInterval|ClientAliveCountMax)" /etc/ssh/sshd_config
sed -i is silent on success; the final grep echoes back all seven directives with your new values, uncommented.- If a directive still shows commented out (
#PermitRootLogin ...) or missing entirely after thesed, 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 withecho "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:
sudo sshd -t
/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_configcan 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):
sudo systemctl restart sshd # RHEL/Fedora, most modern Debian/Ubuntu
sudo systemctl restart ssh # Some Debian/Ubuntu builds name the unit "ssh"
- Silent on success.
- Confirm with
systemctl status sshd(orssh) showingactive (running), and by opening a new SSH session (don't close your current one first) to confirm login still works.
- "Unit sshd.service not found" — try the other unit name (
sshvssshdreally does vary by distro/version);systemctl list-units --type=service | grep -i sshshows the actual unit name on this box if neither guess works. - If the service fails to (re)start (
systemctl statusshowsfailed), re-runsudo 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:
sudo systemctl disable --now sshd
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.
- Silent on success (or two short "Removed symlink..." lines).
systemctl status sshdafterward showsinactive (dead)anddisabled.
- 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#
# 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
- The audit
grepat the top prints the current values of each named directive for a quick sanity check. ufw allow from ...(if using ufw) confirms withRule added.ss -tulpn | grep sshshows the listening port/PID for sshd (default:22unless changed).- The
ls/permission commands print file listings; thechmod 600is silent. sshd -T | grep -iE ...— unlike a plaingrepon 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.
sshd -Tshowing a DIFFERENT value than what's in your editedsshd_configfile is the important one to catch — it means something else (aMatchblock further down the file, or a file undersshd_config.d/read after the main config) is overriding your edit; checkls -la /etc/ssh/sshd_config.d/and grep those files for the same directive names.- If
Banner /etc/issue.netdoesn'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 600onsshd_configitself is safe and expected; ifsshdthen 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, andClientAliveInterval/ClientAliveCountMaxare all named CIS Level 1 controls, not just this doc's opinion.LogLevel VERBOSE, disablingGSSAPIAuthentication/HostbasedAuthentication, thesshd_configfile permission (600),DisableForwarding,PermitUserEnvironment no,UsePAM yes, and a configuredBannerare additional CIS Level 1 items commonly missed.
CIS Level 2 to try if time allows: restrict
Ciphers,MACs, andKexAlgorithmsto 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_confighardening 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#
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
ufw enableprintsFirewall is active and enabled on system startup.ufw status verbosethen listsStatus: active, the default policies, and each allow rule (e.g.22/tcp ALLOW IN Anywhere).
- If you're connected over SSH and forgot the
allow 22/tcpline beforeufw 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 withufw status(before "enable" even), then enable. - If already locked out this way, you need local/console/hypervisor access to fix it (
ufw disableor add the missing rule from the console). "ERROR: problem running ufw-init" onenablesometimes 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#
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
firewall-cmd --list-all prints the active zone's config block including services: ssh (if added) and target: DROP.- Same lockout risk as ufw above — always add your
--add-service=ssh(or equivalent) rule before--reload, and always use--permanentor the rule vanishes on the next reload/reboot (a rule added without--permanentis only live in the current runtime config, easy to lose track of). "Error: INVALID_SERVICE" means the service name is wrong —firewall-cmd --get-serviceslists every valid service name this system knows. - If
--set-default-zone=dropseems too aggressive and breaks something unexpected,publicis a gentler default zone that still blocks most unsolicited inbound traffic while being less absolute thandrop.
- 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#
# 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
-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.
is-enabledprintsenabled.- Raw
iptables -Lshows the actual kernel-level rule chains ufw/firewalld generated (useful to eyeball for anything unexpected). fail2baninstall/enable behaves like any other package+service.ufw status numberedprefixes each rule with[N]for use withdelete <N>.- The
nc -zvchecks print "succeeded" or "Connection refused"/timeout depending on whether that port is actually reachable.
is-enabledprintingdisabledmeans the firewall rules are active right now but won't survive a reboot — fix withsudo systemctl enable ufw(orfirewalld).nc: command not found— installnetcat/ncat/nmap-ncatdepending on distro, or substitutetimeout 2 bash -c "</dev/tcp/localhost/22" && echo openas 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 disableorsudo 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:
sudo ss -tulpn
sudo netstat -tulnp # if netstat/net-tools installed
users:(("sshd",pid=812,fd=3))).netstat: command not found— it's deprecated/not installed by default on many modern distros (part of the oldnet-toolspackage);ssis the actively-maintained replacement and is what you should rely on primarily,netstathere 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 withsudo, 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:
systemctl list-unit-files --type=service --state=enabled
systemctl list-units --type=service --state=running
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-allor check/etc/init.d/. - Disable/stop/purge unapproved services (do not remove anything the README requires):
sudo systemctl disable --now <service_name>
sudo apt purge -y <package_name> # Debian/Ubuntu
sudo dnf remove -y <package_name> # Fedora/RHEL
disable --nowis silent or prints a couple of "Removed symlink" lines and immediately stops the service.purge/removeprint normal package-manager removal output ending in a summary line.
- "Unit not found" on
disablemeans 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 (checkcrontab -l,/etc/xinetd.d/,/etc/rc.local). apt purgefailing with unmet dependency errors on a package other software depends on — don't force it blindly (--forceflags 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 --nowmay 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#
# 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'
disable --now avahi-daemon/cupsbehave like any other service disable.lsof -i :<port>prints the process holding that port (similar info toss -tulpnbut sometimes easier to read for a single port).systemd-analyze securityprints a table of every unit with an exposure score (UNSAFEdown toSAFE) — informational, not pass/fail.list-socketsprints activation sockets and their listening address.- The CIS
forloop prints anENABLED (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.
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 securityrefusing 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.
ping -c 2 8.8.8.8
0% packet loss.- 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 afor a valid IP andip rfor a default route before concluding the network itself is intentionally cut off.
- Debian/Ubuntu/Mint:
sudo apt update && sudo apt upgrade -y && sudo apt dist-upgrade -y
sudo apt autoremove -y
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.- "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/lockshows 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):
sudo yum update -y
- Fedora/CentOS 8+/RHEL 8+ (dnf):
sudo dnf update -y
sudo dnf upgrade --refresh -y
Complete!.- 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.
- Check the installed bash version:
bash --version
GNU bash, version 5.1.16(1)-release ....- This essentially never errors — if nothing prints, you're not actually in a bash shell (check
echo $SHELL/ps -p $$); some minimal images default todashorshfor scripting even if bash is installed. 2. - Run the quick Shellshock test — it should print
testonly; if it also printsvulnerable, the bash on this box is unpatched:
env x='() { :;}; echo vulnerable' bash -c "echo test"
- just
test. - Seeing
vulnerableprinted beforetestmeans this bash is exploitable.
- 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:
sudo apt update && sudo apt install --only-upgrade bash # Debian/Ubuntu
sudo yum update bash # RHEL/CentOS
bash package specifically.- 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/yumreport bash is already at the "latest" version but the Shellshock test in step 2 still showsvulnerable, 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#
# 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)
apt list --upgradablelists package names with old→new version.apt-mark showholdprints package names held back from upgrades (often empty — that's normal).apt upgrade -s(simulate) prints what WOULD happen without changing anything.gpgcheckgrep should showgpgcheck=1for every repo file, no exceptions.
- Finding a repo with
gpgcheck=0is a real, scorable finding — fix by changing it togpgcheck=1in that.repofile, 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 foundon RHEL 7 meansyum-utilsisn't installed (sudo yum install yum-utils -yfirst) — 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:
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
rkhunter --checkruns an interactive-looking series of checks (press Enter to move through, or add--skto skip pauses) ending in a summary of Warning/OK counts, with a full log at/var/log/rkhunter.log.chkrootkitprints one line per check ending innot infected,INFECTED,not tested, ornot found.
- 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 --updatefailing 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--checkanyway if offline.
- Install and run ClamAV for malware signatures:
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
freshclamprints database update progress ending in something like "Database updated".clamscanprints a per-file scan line for infected files only, then a summary block (files scanned, infected count).
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
rkhunterandchkrootkitproduce false positives on stock systems; don't blindly "fix" things they flag.
Things to try / extra points#
# 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)
- ClamTK installs like any GUI package; launching it shows a simple window with Scan/Update/History tabs.
- Background
clamscanwrites progress to the log file you cantail -flater. rkhunter --propupdprints a short confirmation the file properties database was updated.aideinit/aide --inittakes a while (it's hashing much of the filesystem) and ends by writing a new database file (often needing a rename fromaide.db.newtoaide.dbbefore first real use — the tool's own output tells you the exact filenames on this build).
aideinit/aide --initcan 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 pathaideexpects by default — check the init command's own final output line for the exact source filename and copy/rename it into place (commonlysudo 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:
dpkg -l # Debian/Ubuntu/Mint — full package list
rpm -qa # RHEL/Fedora/CentOS — full package list
dpkg -l; just names for bare rpm -qa).- This basically can't fail — if the list looks suspiciously short, you may be filtering/piping it through something (e.g.
| grepwith a typo'd pattern) — run it bare first. - Pipe to
lessor 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:
sudo apt install synaptic -y && sudo synaptic # Debian/Ubuntu/Mint
- Won't install without a network connection and a working repo — if offline, skip this and rely on the
dpkg -l/rpm -qatext output instead, which needs nothing extra. - If it installs but won't launch under
sudodue to a display/X11 permission issue, try launching it from the applications menu instead of the terminal, orsudo -E synapticto preserve your display environment variables.
- Search for known hacking/pentest tools and remove any not explicitly authorized:
which nmap zenmap netcat nc ncat john hydra aircrack-ng wireshark tcpdump nikto ophcrack metasploit msfconsole 2>/dev/null
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).- No error mode here — every hit needs a judgment call, not an automatic removal.
- Cross-check each hit against the README before removing:
tcpdumpandnc/netcatin 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-openbsdas if it were contraband. Stock Ubuntu/Debian shipnetcat-openbsdpre-installed as a dependency of other packages (it provides the plainncbinary 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:bashdpkg -S $(which nc) # shows which package owns the nc binary dpkg -l | grep netcat # netcat-openbsd = normal; netcat-traditional = often the flagged oneThe real red flags are:
netcat-traditionalspecifically (a different package, more often the one competition scoring checks for), a standalonenc/ncatbinary 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 -Sreturns nothing). Those are worth removing; the defaultnetcat-openbsddependency on a stock Ubuntu box is not.
- Common blacklist to check/purge (verify against README first — some scenarios legitimately require a web server):
sudo apt purge -y nmap zenmap wireshark tcpdump netcat-traditional nikto ophcrack apache2 nginx lighttpd samba smbclient
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).apt purgeerrors 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 apache2etc. 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):
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:
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
- No real error mode for
finditself; a huge/slow result on a disk with lots of legitimate content just means you need to eyeball more carefully — pipe to| lessor redirect to a file rather than scrolling. - Remember
findsearches are case-sensitive without-iname; the media search already uses-iname(case-insensitive) correctly, but if you write your own variant, matching*.MP3and*.mp3both requires-iname, not-name.
- Remove Samba/SMB unless explicitly required (classic vulnerable/unauthorized service):
sudo apt purge -y samba samba-common smbclient
- 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),aptwill 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#
# 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
- The whole-filesystem media search prints matches beyond just
/home(e.g. stashed in/tmpor/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-settingsprints a.desktopfilename identifying the default browser (e.g.firefox.desktop).
- The
file-based search can be slow across a big/home— that's expected, not a hang. grep " install " /var/log/dpkg.logreturning nothing means the log has already rotated past your window of interest — check/var/log/dpkg.log.1or.gzrotated logs too (zgrep " install " /var/log/dpkg.log.*.gz).xdg-settings: command not foundon 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— useapt purgeso leftover config files don't leave partial credit on the table, and follow withapt autoremoveto 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):
sudo find / -xdev -type f \( -perm -4000 -o -perm -2000 \) -exec ls -l {} \; 2>/dev/null
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.
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.- No real error mode — a long or unfamiliar-looking result is the actual finding to investigate, not a script problem.
-xdevintentionally 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:
sudo chmod u-s /path/to/binary
sudo chmod g-s /path/to/binary
find above and the binary's permission string should no longer show s.- Removing the SUID bit from a binary that genuinely needs it (like
passwdorsudoitself) 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 withsudo 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
/optwith 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 |
ls -l /etc/passwd /etc/shadow /etc/group /etc/gshadow /etc/sudoers
sudo chmod 644 /etc/passwd && sudo chmod 640 /etc/shadow
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.- "Operation not permitted" on the chmod means you're not actually running as root/sudo despite the
sudoprefix — check for a typo dropping thesudo, or that your account genuinely has sudo rights. - If
/etc/shadow's group ownership isn'tshadowafter the chmod (permissions look right butls -lshows a different group), fix ownership separately:sudo chown root:shadow /etc/shadow.
- Set restrictive home directory permissions for regular users:
for i in $(awk -F: '$3 > 999 && $3 < 65534 {print $1}' /etc/passwd); do
[ -d /home/${i} ] && sudo chmod -R 750 /home/${i}
done
-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.
ls -ld /home/* afterward shows drwxr-x--- on each processed home directory.- 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 750on 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#
# 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
diffprints nothing if the SUID list is unchanged from baseline (good), or</>prefixed lines showing exactly what was added/removed if it changed.- The
-perm -4002search (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 -ldchecks print current permission strings to compare against the noted-expected values in the comments.
diff: /root/suid_baseline.txt: No such file or directoryjust 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.txton the currentfindoutput) so future re-checks this round have something to diff against.- If
/boot/grub2/grub.cfgdoesn'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/sudoerspermission 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/python3is 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:
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
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.- "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 withsudo 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:
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/
/etc/crontab prints the system crontab's active lines.- "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
lson acron.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):
systemctl list-timers --all
- "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#
# 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
- The
grepprints any matching suspicious lines found (empty = clean). atq/at -llist pending at-jobs (often empty — that's normal, at-jobs are less commonly used than cron).- The
cron.allow/at.allowblock ends with those two files existing, containingroot(add other authorized usernames on separate lines if needed), owned appropriately, mode 640.
- 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/wgetfor legitimate reasons). atq/at: command not foundmeans theatpackage isn't installed — if it's not installed, there's nothing to restrict viaat.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.allowtoo (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.allowconfigured out of the box.
Systemd timers can silently replace a cron job an attacker removed to look "clean" — always check
systemctl list-timers --alleven if crontabs look empty.
13. World-Writable Files & Unowned Files#
- Find world-writable files (excludes directories, which need the sticky-bit-aware version below):
sudo find / -xdev -type f -perm -0002 -exec ls -l {} \; 2>/dev/null
- A (hopefully short/empty) list of world-writable regular files.
- Any hit is worth individually reviewing.
/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):
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):
sudo find / -xdev \( -nouser -o -nogroup \) -print 2>/dev/null
- 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/groupentry 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.
- Fix world-writable files by removing the
o+wbit unless there's a specific reason (e.g.,/tmp) for it to remain:
sudo chmod o-w /path/to/file
- "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 withlsattr /path/to/fileand clear it first withsudo chattr -i /path/to/fileif so (see Section 14's coverage of this same trick used for persistence). - Don't remove write access from
/tmp,/var/tmp, or/dev/shmthemselves — 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#
# 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
- The
/home-scoped search is much faster than a full/scan since it's a smaller tree. chmod +tis silent; confirm withls -ld /tmpshowing a trailingtin the permission string (drwxrwxrwt).
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.
-xdevkeepsfindfrom 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:
cat /home/*/.bash_history /root/.bash_history 2>/dev/null
cat /home/*/.sh_history 2>/dev/null
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:
sudo find / -xdev -name ".*" -type f 2>/dev/null | grep -vE "^/home|^/root|^/etc"
/tmp, /var, or /opt — legitimate dotfiles mostly live under /home, /root, or /etc, which this search deliberately excludes to cut noise./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
$PATHhijacks:
grep -rnE "alias|export PATH|function" /etc/profile /etc/bash.bashrc /etc/environment /home/*/.bashrc /root/.bashrc 2>/dev/null
- 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 aPATHexport that prepends a world-writable directory ahead of/usr/bin.
- No error mode; this is a read-and-judge task, not pass/fail.
- If you find a genuine
PATHhijack (a directory like/tmpor a user-writable folder placed before/usr/binin$PATH), remove that entry from the offending rc file and confirm withecho $PATHin 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:
sudo lsattr -R /etc /var /home 2>/dev/null | grep '\----i'
# unlock a flagged file before editing/removing:
sudo chattr -i /path/to/file
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.lsattr: Inappropriate ioctl for devicefor 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.preloadand/etc/ld.so.conf.d/for forced shared-library injection (classic rootkit technique):
cat /etc/ld.so.preload 2>/dev/null
ls -la /etc/ld.so.conf.d/
- On a clean system,
/etc/ld.so.preloadtypically doesn't exist at all (thecatprints nothing and errors silently due to2>/dev/null— that's the good outcome). ld.so.conf.d/normally contains only recognizable, package-installed.conffiles.
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.preloadexists and references a non-standard.sopath, delete it immediately — it forces that library into every process on the system.
Things to try / extra points#
# 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
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
PATHchecks print each account's effective search path. - The PAM
.soaudit's final grep should print nothing (every referenced module lives in the standard library path); thelsshows what's actually installed there for comparison.
- "Permission denied" reading
/proc/<pid>/environfor 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
PATHthat 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
.soreference in a PAM config file that survives thegrep -vfilters (meaning it's NOT in/lib/or/usr/lib/) is worth investigating directly — open that specificpam.dfile and check where the referenced module actually points.
15. Log Review#
- Review authentication logs for brute-force attempts, unexpected logins, or sudo abuse:
grep sshd.*Failed /var/log/auth.log | less # Debian/Ubuntu/Mint
sudo grep "Failed password" /var/log/secure # RHEL/Fedora/CentOS
less, each showing timestamp, source IP, and username attempted.- "No such file or directory" on
/var/log/auth.logon a Debian/Ubuntu-FAMILY box usually means logging has moved to the systemd journal only (no flat-file auth log written) — usejournalctlinstead: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.1too.
- 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 |
— |
sudo less /var/log/auth.log
sudo less /var/log/syslog
journalctl -xe
journalctl -k
less-paged log contents (use/searchterminsidelessto search,qto quit).journalctl -xejumps to the end of the systemd journal with extra context added to some entries;journalctl -kshows kernel-ring-buffer messages only.
- File-not-found on
/var/log/syslog(RHEL family uses/var/log/messagesinstead — check the table above) is a distro-family mismatch, not an error in the command itself. journalctlcomplaining about needing to be in thesystemd-journalgroup or run as root — prefix withsudo.
- Set up auditd for ongoing kernel-level auditing:
sudo apt install auditd -y # Debian/Ubuntu/Mint
sudo dnf install audit -y # Fedora/RHEL
sudo auditctl -e 1
sudo systemctl enable --now auditd
auditctl -e 1 prints AUDIT_STATUS: enabled=1 ...; systemctl enable --now auditd starts the service silently or with a couple of "Created symlink" lines.- "Unable to set enabled flag to 1" on
auditctlsometimes 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) — checksudo auditctl -sfor current status first, this may already be exactly what you want. - Package name differs by family — Debian/Ubuntu is
auditd, Fedora/RHEL isaudit(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.confon some older doc references).
Things to try / extra points#
# 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"
- 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 -bshows 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.confgrep shows the configured rotation cadence/retention.
grep -creturning0for 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.1or usejournalctlinstead, which retains more history by default in many configurations.- If
augenrules --loadisn't found,auditdon this build may use a different rule-loading mechanism — theservice auditd restartfallback 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/setxattrcalls (permission-change auditing) and kernel module loading (init_module/delete_modulesyscalls) — 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:
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades
dpkg-reconfigure opens a text-mode dialog asking "Automatically download and install stable updates?" — select Yes.- 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-upgradesdirectly to the two lines shown below instead — same end result. dpkg-reconfigure: unable to re-open stdinwhen run through certain non-interactive shells/scripts — run it from a normal interactive terminal session instead. Verify/etc/apt/apt.conf.d/20auto-upgradescontains:
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
"1" (confirm with cat /etc/apt/apt.conf.d/20auto-upgrades).sudo tee — it's a plain text file, no special generator required.RHEL/CentOS/Fedora:
sudo dnf install dnf-automatic -y
sudo systemctl enable --now dnf-automatic.timer
active in systemctl status dnf-automatic.timer.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#
# 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
systemctl statusshowsactive (running)oractive (waiting)for a timer.- The dry-run prints verbose debug output showing which packages it WOULD upgrade, without actually installing anything.
- "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 unattendedbefore 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 |
sudo apt install gnome-system-tools gufw synaptic clamtk bum -y
- "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-toolssimilarly 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,
aptmay 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/fstabmount-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):
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):
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):
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):
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):
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):
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:
sudo sysctl -p /etc/sysctl.d/99-security.conf
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.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/procentries 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.confdirectly and check that exact line against what's documented here. - Values not surviving a reboot despite
sysctl -pworking 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 realsysctl.ddirectory 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/shadowand OpenSSH host private keys.kernel.yama.ptrace_scope = 2in 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 setnet.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):
echo "* hard core 0" | sudo tee -a /etc/security/limits.conf
- This alone doesn't guarantee no core dumps get written —
fs.suid_dumpable = 0from 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 ownRLIMIT_COREinternally).
- Prevent IP spoofing via
/etc/host.conf:
grep -q "nospoof on" /etc/host.conf || echo "nospoof on" | sudo tee -a /etc/host.conf
grep -q found it and the || short-circuited); otherwise the new line echoes and gets appended.- 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.confdoesn't exist at all on this distro (some modern systems have deprecated it in favor ofnsswitch.conf-only resolution), thetee -awill 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):
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
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.- "mount: /tmp: must be superuser" — you're missing
sudo. - More commonly, the remount silently has no lasting effect if
/tmp//var/tmparen'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/fstabline (which specifically targets/dev/shm, a tmpfs that DOES always exist as its own mount) will actually apply; don't assume the/tmp//var/tmpremounts 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):
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:
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:
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
- each heredoc/echo writes the new
.conffile 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
lsmodshows one of these modules already loaded (common forudfif a USB/optical drive was mounted earlier in the session), theinstall ... /bin/trueblock 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#
# 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
sysctl -w kernel.modules_disabled=1prints the new value back.lsmodlists all currently loaded kernel modules with size/use-count.- The
sudo sysctlverification line prints all four requested values in one shot. rfkill listshows wireless/Bluetooth radio state (blocked/unblocked).
- After setting
kernel.modules_disabled=1, ANY furthermodprobe/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— installutil-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=1is a one-way door until reboot — apply it last, after you're confident you won't needmodprobefor 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/gsettingson GNOME-based Mint/Ubuntu):
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):
gsettings set org.cinnamon.desktop.screensaver lock-enabled true
gsettings set org.cinnamon.desktop.session idle-delay 300
- silent on success.
- Verify with the matching
gsettings get(e.g.gsettings get org.gnome.desktop.screensaver lock-enabledshould printtrue).
- "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_DESKTOPorecho $DESKTOP_SESSION) and use the matching block. gsettingsalso 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 viadconfsystem-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):
sudo sed -i 's/^autologin-user=.*/#autologin-user=/' /etc/lightdm/lightdm.conf
grep autologin /etc/lightdm/lightdm.conf afterward shows the line commented out.- "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.confwith an[daemon]section'sAutomaticLoginEnable=/AutomaticLogin=keys. - If the
sedruns but autologin still happens on next boot, the setting may be duplicated in a.confsnippet under/etc/lightdm/lightdm.conf.d/that's read after (and overrides) the main file — check there too.
Things to try / extra points#
# 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
- The autologin grep prints nothing if clean.
- The dconf/media-automount block writes two files and a lock entry, then
dconf updatecompiles the database silently. - The XDMCP grep should print nothing (XDMCP section absent or
Enable=false).
dconf updateerroring about a malformed.dfile usually means a typo in the heredoc's[section]/key=valuesyntax — 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-handlingschema specifically — it does nothing under Cinnamon/MATE, which have their own equivalent settings under a different schema path). - An XDMCP hit (
Enable=truefound) is a real, fixable finding — set it toEnable=falseunder the[xdmcp]section of/etc/gdm3/custom.confand 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:
sudo aa-status
sudo systemctl enable --now apparmor
sudo aa-enforce /etc/apparmor.d/*
aa-statusprints counts of profiles loaded, in enforce mode, and in complain mode, plus which processes are unconfined.aa-enforceon the whole directory switches every loadable profile to enforce mode, printing one confirmation line per profile.
- "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 foundmeans theapparmor-utilspackage (separate from baseapparmor) isn't installed —sudo apt install apparmor-utils -yfirst.
- Identify network-listening processes running unconfined (no active MAC profile):
sudo aa-unconfined --active
not confined next to a process name is the finding to investigate.aa-unconfined: command not found— sameapparmor-utilsdependency asaa-enforceabove.- 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:
sestatus
sudo setenforce 1 # force Enforcing mode for current boot
sudo sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config # persist across reboots
sestatusprintsSELinux status: enabledandCurrent mode: enforcingafter thesetenforce.- The
sedline makes it survive a reboot too.
setenforce: SELinux is disabledmeans SELinux isn't just in permissive mode, it's fully off at the kernel level — that requires aselinux=0/enforcing=0boot parameter to be removed from GRUB (see the CIS bootloader check right below this block) AND a reboot to take effect;setenforcealone 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#
# 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
- The complain-mode grep prints any AppArmor profiles NOT enforcing.
ausearch -m avc -ts recentprints 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 -qshows whether the troubleshooting packages are installed at all before trying to remove them.
ausearch: command not foundneeds theaudit/auditdpackage (see Section 15) — install it first if you need this specific check.- A hit on the GRUB parameter check (
selinux=0orapparmor=0found) is a real, high-priority finding: edit/etc/default/grub'sGRUB_CMDLINE_LINUXline to remove the offending parameter, then regenerate the boot config (sudo grub2-mkconfig -o /boot/grub2/grub.cfgorsudo update-grubon Debian/Ubuntu) and reboot — a bootloader-level disable completely defeats anysetenforce/config-file change you make while the system is running. rpm -qon a Debian/Ubuntu box (norpmcommand) — 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/sestatusalone 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):
sudo find /dev -type f
/dev should contain only device nodes (character/block special files), sockets, and symlinks, never regular files./dev is a classic rootkit stash location, since most admins never think to look there).- Hidden/rogue processes — compare
/procPIDs against whatpsreports (rootkits sometimes hide PIDs fromps/topbut can't hide the/procentry):
comm -23 <(ls -d /proc/[0-9]* | awk -F/ '{print $3}' | sort -n) <(ps -A -o pid= | awk '{print $1}' | sort -n)
/proc should also show up in ps output.- A PID appearing here that
psdoesn'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 corecoreutils) — if truly missing, compare the two lists manually withdiff <(...) <(...)instead.
- Systemd drop-in overrides — malware can persist by adding an
override.confto a legitimate service's systemd directory:
sudo find /etc/systemd/system/ -name "*.conf" -o -name "*.service"
/lib/systemd/system/ or /usr/lib/systemd/system/ locations — some of these are legitimate (locally-created services), some may not be.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
.soreferenced in/etc/pam.d/*resolves to a legitimate system library path:
grep -vE '^#|^$' /etc/pam.d/* | grep -v 'pam_'
grep -r "so" /etc/pam.d/ | grep -v "/lib/" | grep -v "/usr/lib/"
- 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).
.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):
for p in $(sudo fuser <port>/tcp 2>/dev/null); do readlink -f /proc/$p/exe; done
/usr/sbin/sshd).fuser: command not found— it's part ofpsmisc, install withsudo apt install psmisc -y(Debian/Ubuntu) or usess -tulpn | grep :<port>(already covered in Section 7) as afuser-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#
# 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
lsmod | sortprints every loaded kernel module alphabetically for easier eyeballing.- The
LD_PRELOADgrep should print nothing on a clean system.
- No error mode for either.
- An unfamiliar module name in
lsmodisn't automatically malicious — many are legitimate hardware/filesystem drivers; cross-check an unfamiliar name withmodinfo <name>(shows the module's description and source) before treating it as suspicious. - Any
LD_PRELOADhit 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.preloadbut 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
.soinjection, hidden/devfiles, 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):
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:
Options -Indexes -FollowSymLinks -Includes -ExecCGI
- Restart Apache after config changes and test syntax first:
sudo apache2ctl configtest # Debian/Ubuntu
sudo apachectl configtest # RHEL/Fedora
sudo systemctl restart apache2 # Debian/Ubuntu
sudo systemctl restart httpd # RHEL/Fedora
configtestprintsSyntax OK.- The restart is silent on success; confirm with
systemctl status apache2/httpdshowingactive (running).
- Any syntax error message from
configtestnames 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 (
apache2is Debian/Ubuntu's name,httpdis 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).
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"
- 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 -sfor the actually-effective values instead) rather than the file having no shares configured. - After editing
smb.conf, always runsudo testparmbefore restarting — likeapache2ctl 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:
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)';"
- The
SELECTprints any anonymous-user rows found (empty result set = already clean). - The
DROP USERstatements are silent on success (or printQuery OK).
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:
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
- The first query lists every
rootrow (usually justroot@localhost). - The second should return an empty result — any row it does return is a remote-root-login finding.
- Same auth caveats as the anonymous-user block above apply here.
- If the second query does return a row, don't just
DROPit 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:
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
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.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:
sudo mysql_secure_installation
- "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:
sudo systemctl restart mysql # Debian/Ubuntu
sudo systemctl restart mariadb # RHEL/Fedora / some Debian variants
systemctl status afterward shows active (running).- "Unit not found" — try the other name (
mysqlvsmariadbvsmysqlddepending on distro/fork);systemctl list-units | grep -i sqlshows 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— checksudo journalctl -u mysql -n 50(or the mariadb equivalent) for the specific parse error.
Things to try / extra points#
# 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
ps aux | grep apache2shows worker processes running as thewww-data/apacheuser, NOT root (one root-owned master process managing them is normal — that's Apache's standard privilege-drop pattern, not a vulnerability).- The
curldirectory-listing check prints matching lines only if listing is actually exposed live. - The
configtest/mysqld --validate-configre-checks confirm your edits parsed cleanly. - The nginx grep and vhost-override checks print any matching lines found in those files.
- Seeing MULTIPLE processes all running as root (not just one master) means
User apache/Group apacheisn't actually taking effect — recheck you edited the config Apache is actually loading (apache2ctl -Sshows the active config file path) and restarted after the edit. - The
curlcheck 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-enabledfor 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.
# --- 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
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.