A security audit answers a simple question: does the server look the way you think it does? Over time packages get installed, users get added, ports get opened for a test and never closed. This checklist walks through a manual audit of an Ubuntu 24.04 server, area by area, with the command to run, what a healthy result looks like and what to do when it does not. It works equally well for a first review of a server you inherited and as a periodic check.
Prerequisites
To follow this checklist you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS. Most commands also work on Debian 12.
- A user with
sudoprivileges. - A list of what the server is supposed to run (services, users, open ports). The audit is about finding differences from that list.
The checks only read the system, apart from installing the debsums package in Step 8. The few fixes shown along the way are optional: apply them after the audit, one at a time, and test each change.
Step 1 - Preparing a place for the results
Save the output of each check, so you can attach it to your notes and compare it with the next audit. Create a dated directory that only you can read:
mkdir -p ~/audit-$(date +%F)
chmod 700 ~/audit-$(date +%F)
cd ~/audit-$(date +%F)
When a command's output is worth keeping, pipe it through tee, for example sudo ss -tulpn | tee ports.txt. Record the basic system facts first:
hostnamectl | tee system.txt
uptime | tee -a system.txt
Step 2 - Updates and reboots
Unpatched software is the most common way servers get compromised, so start here. Refresh the package lists and see what is pending:
sudo apt update
apt list --upgradable
Healthy result: no packages, or only a few released in the last days. A long list means updates are not being applied.
Check whether a reboot is required to load a new kernel or libraries:
cat /var/run/reboot-required 2>/dev/null || echo "No reboot required"
Verify that automatic security updates are enabled:
cat /etc/apt/apt.conf.d/20auto-upgrades
systemctl is-active unattended-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
active
If the file is missing or the values are "0", install and enable the feature with sudo apt install unattended-upgrades followed by sudo dpkg-reconfigure -plow unattended-upgrades.
Step 3 - Users and privileges
Accounts with UID 0
Only root should have user ID 0. Any other account with UID 0 is a full root account under a different name:
awk -F: '$3 == 0 {print $1}' /etc/passwd
root
Accounts that can log in
List the accounts that have a real login shell:
grep -Ev '(/usr/sbin/nologin|/bin/false|/usr/bin/false)$' /etc/passwd | cut -d: -f1,3,7
root:0:/bin/bash
sync:4:/bin/sync
your_user:1000:/bin/bash
Every human account on the list should belong to someone who still needs access. System accounts (UID below 1000) other than root and sync should not have a shell.
Empty passwords and locked accounts
No account should have an empty password field:
sudo awk -F: '$2 == "" {print $1}' /etc/shadow
Healthy result: no output. Check the password status of each human account:
sudo passwd -S -a | awk '$2 == "P"'
This lists accounts with a usable password (P). Accounts that log in only with SSH keys can have their password locked with sudo passwd -l username.
Sudo access
List members of the sudo group and any extra rules in the sudoers files:
getent group sudo
sudo grep -rEv '^\s*(#|$)' /etc/sudoers /etc/sudoers.d/
Look for users that should not be administrators and for NOPASSWD rules. NOPASSWD is acceptable for a dedicated automation account with a narrow command list, not for general users.
Recent logins
Review who logged in recently and from where:
last -n 20 -a
Check failed login attempts, which are recorded in /var/log/btmp:
sudo lastb -n 20 -a
Unknown source addresses in last, or successful logins at unusual times, deserve a closer look.
Step 4 - SSH configuration
The configuration files can include each other, so audit the effective configuration that the SSH daemon actually uses:
sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|permitemptypasswords|maxauthtries|x11forwarding|allowusers|allowgroups) '
permitrootlogin without-password
pubkeyauthentication yes
passwordauthentication no
kbdinteractiveauthentication no
permitemptypasswords no
maxauthtries 3
x11forwarding no
Recommended values:
| Option | Recommended |
|---|---|
permitrootlogin | no (or without-password if you really need key-only root access) |
passwordauthentication | no, once every user has a working SSH key |
kbdinteractiveauthentication | no |
permitemptypasswords | no |
maxauthtries | 3 to 4 |
x11forwarding | no on servers |
Next, review which SSH keys can log in. Every key in these files grants access:
sudo find /root /home -name authorized_keys -exec sh -c 'echo "== $1"; cat "$1"' _ {} \;
Remove keys you cannot attribute to a current person or system. The comment at the end of each key line usually says who it belongs to.
Step 5 - Network: open ports and firewall
List every listening TCP and UDP socket with the process that owns it:
sudo ss -tulpn | tee ports.txt
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=612,fd=13))
tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=901,fd=3))
tcp LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1102,fd=6))
tcp LISTEN 0 151 127.0.0.1:3306 0.0.0.0:* users:(("mysqld",pid=1203,fd=21))
Read the Local Address column:
127.0.0.1or[::1]: reachable only from the server itself. Databases and caches should be here.0.0.0.0or[::]or*: reachable on all interfaces. Each of these must be a service you intend to expose.
Then check the firewall:
sudo ufw status verbose
Status: active
Default: deny (incoming), allow (outgoing), deny (routed)
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80,443/tcp ALLOW IN Nginx Full
Healthy result: the firewall is active, the default incoming policy is deny, and only ports you need are allowed. If UFW reports Status: inactive, allow SSH with sudo ufw allow OpenSSH before running sudo ufw enable.
Also look at current connections, to spot unexpected outbound traffic such as a process talking to an unknown host:
sudo ss -tpn state established
Step 6 - Services and scheduled tasks
List running services and compare them with what the server is supposed to do:
systemctl list-units --type=service --state=running --no-pager
Disable services you do not need, for example sudo systemctl disable --now cups. Also check for failed units, which may indicate a broken security component:
systemctl --failed
Attackers often persist through scheduled tasks. Review all of them:
sudo ls -la /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly
sudo cat /etc/crontab
sudo ls -la /var/spool/cron/crontabs
systemctl list-timers --all --no-pager
To read a user's crontab, run sudo crontab -l -u username. Every entry should have a known purpose.
Step 7 - File permissions
Critical system files
stat -c '%a %U:%G %n' /etc/passwd /etc/group /etc/shadow /etc/gshadow /etc/ssh/sshd_config
644 root:root /etc/passwd
644 root:root /etc/group
640 root:shadow /etc/shadow
640 root:shadow /etc/gshadow
644 root:root /etc/ssh/sshd_config
These are the Ubuntu defaults. /etc/shadow and /etc/gshadow must never be world readable.
SUID and SGID binaries
Programs with the SUID or SGID bit run with the owner's or group's privileges, so an unexpected one is a classic privilege escalation path:
sudo find / -xdev -type f \( -perm -4000 -o -perm -2000 \) -exec ls -l {} \; | tee suid.txt
A standard Ubuntu server has around 20 to 30, such as /usr/bin/passwd, /usr/bin/sudo and /usr/bin/su. Investigate anything in /home, /tmp, /var/www or /opt, and check which package owns a file with dpkg -S /path/to/file. A binary no package owns is suspicious.
World-writable files and directories
sudo find / -xdev -type f -perm -0002 -ls
sudo find / -xdev -type d -perm -0002 ! -perm -1000 -ls
The first command lists world-writable files and should return nothing. The second lists world-writable directories without the sticky bit (unlike /tmp, which has it) and should also return nothing.
Files without an owner
Files owned by a deleted user or group can be claimed by a new account that gets the same ID:
sudo find / -xdev \( -nouser -o -nogroup \) -ls
Assign them to the right owner or remove them.
Step 8 - Package integrity
debsums compares installed files against the checksums shipped in each package, which reveals modified system binaries and configuration:
sudo apt install debsums
sudo debsums -s
With -s, only problems are printed. No output means all packaged files match. To list modified configuration files, which is often expected, run:
sudo debsums -ce
Every changed file in the output should be one that you or your configuration management edited on purpose.
Step 9 - Logs
Confirm that logging works and look at recent problems. Show errors from the current boot:
sudo journalctl -p err -b --no-pager | tail -n 30
Count failed SSH password attempts in the last 24 hours:
sudo journalctl -u ssh --since "24 hours ago" --no-pager | grep -c "Failed password"
A high number with password authentication still enabled is a strong reason to switch to key-only logins and install Fail2Ban.
Check that the journal keeps enough history and fits on disk:
journalctl --disk-usage
sudo journalctl --list-boots --no-pager | head -n 3
If the oldest boot is only a few hours old, logs are being rotated too quickly to investigate an incident. Raise SystemMaxUse in /etc/systemd/journald.conf.
Step 10 - Kernel network settings
Check the sysctl values that affect network security:
sysctl net.ipv4.tcp_syncookies net.ipv4.conf.all.accept_redirects net.ipv4.conf.all.send_redirects net.ipv4.conf.all.accept_source_route net.ipv4.conf.all.rp_filter kernel.randomize_va_space net.ipv4.ip_forward
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 2
kernel.randomize_va_space = 2
net.ipv4.ip_forward = 0
The output above shows the desired values for a server that is not a router. net.ipv4.ip_forward = 1 is normal on hosts that run Docker, Kubernetes or a VPN, and suspicious anywhere else.
On a default Ubuntu install, accept_redirects and send_redirects are usually 1. To change them, create a drop-in file:
sudo nano /etc/sysctl.d/99-hardening.conf
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
Apply it with sudo sysctl --system and run the check again.
Step 11 - TLS certificates
If the server hosts websites, check when their certificates expire. For Let's Encrypt certificates managed by Certbot:
sudo certbot certificates
For any certificate file, or a live site:
sudo openssl x509 -in /etc/ssl/certs/your_certificate.pem -noout -subject -enddate
echo | openssl s_client -connect your_domain:443 -servername your_domain 2>/dev/null | openssl x509 -noout -enddate
Certificates expiring in less than 30 days whose renewal is not automated need action.
Step 12 - Summarizing the findings
Write down each finding with a priority, so the fixes can be planned:
| Priority | Examples | Fix within |
|---|---|---|
| Critical | Extra UID 0 account, unknown SSH key, unknown SUID binary, database listening on all interfaces | Immediately |
| High | Pending security updates, password SSH login for root, firewall disabled | 1 to 3 days |
| Medium | Unneeded services running, NOPASSWD sudo rules, short log retention | 2 weeks |
| Low | Minor hardening values, documentation gaps | Next maintenance window |
Keep the audit directory with your notes. At the next audit, diff the saved outputs (for example diff ~/audit-2026-09-24/suid.txt ~/audit-2026-12-24/suid.txt) to see exactly what changed.
Conclusion
You audited updates, accounts, SSH, network exposure, services, scheduled tasks, file permissions, package integrity, logs, kernel settings and certificates, and saved the evidence for comparison. Repeat the audit every quarter and after any major change. To automate part of this work, run Lynis for a scored audit of hundreds of checks, protect SSH with Fail2Ban, and scan uploaded content for malware with ClamAV.
