Linux Security Fundamentals
Why This Matters
It is 2:00 AM. Your phone buzzes. A monitoring alert says your web server is sending
outbound traffic to an IP address in a country you have no business with. Someone has
exploited a forgotten test account with the password test123, escalated privileges
through a world-writable script, and is now exfiltrating your customer database.
This is not fiction. It happens every day. The difference between the teams that get breached and the teams that do not is rarely some expensive appliance -- it is the disciplined application of basic security principles. Strong passwords, minimal privileges, reduced attack surfaces, and automated patching stop the vast majority of real-world attacks. This chapter gives you the mindset and the concrete tools to harden a Linux system from the ground up.
Try This Right Now
Before we discuss theory, get a quick snapshot of your system's security posture:
# Who is logged in right now?
who
# Any users with UID 0 (root-equivalent)?
awk -F: '$3 == 0 {print $1}' /etc/passwd
# Find all SUID binaries (programs that run as their owner, often root)
find / -perm -4000 -type f 2>/dev/null
# What ports are listening for connections?
ss -tlnp
# When did each user last change their password?
sudo chage -l root
Run these commands on a system you manage. If any output surprises you, good -- that is exactly the kind of surprise we want to find before an attacker does.
The Security Mindset
Security is not a product you install. It is a way of thinking about every decision you make on a system. Three principles form the foundation.
Defense in Depth
Never rely on a single layer of protection. Think of it like a medieval castle:
+-----------------------------------------------+
| Internet |
| +------------------------------------------+ |
| | Firewall (iptables/nftables) | |
| | +-------------------------------------+ | |
| | | Network segmentation (VLANs) | | |
| | | +--------------------------------+ | | |
| | | | SSH key-only access | | | |
| | | | +---------------------------+ | | | |
| | | | | File permissions | | | | |
| | | | | +---------------------+ | | | | |
| | | | | | SELinux / AppArmor | | | | | |
| | | | | | +---------------+ | | | | | |
| | | | | | | Your Data | | | | | | |
| | | | | | +---------------+ | | | | | |
| | | | | +---------------------+ | | | | |
| | | | +---------------------------+ | | | |
| | | +--------------------------------+ | | |
| | +-------------------------------------+ | |
| +------------------------------------------+ |
+-----------------------------------------------+
If the firewall has a gap, SSH keys still protect login. If a key is compromised, file permissions limit damage. If permissions are bypassed, SELinux provides a last line of defense. Each layer is independent.
Least Privilege
Every user, process, and program should have the absolute minimum permissions needed
to do its job -- and nothing more. A web server does not need to read /etc/shadow.
A database process does not need to bind to port 22. A developer does not need
sudo su - on a production server.
Attack Surface Reduction
Every running service, open port, installed package, and user account is a potential entry point. The smaller the surface, the fewer targets an attacker has.
MORE RISK LESS RISK
+------------------+ +------------------+
| 47 services | | 5 services |
| 200 packages | Harden ----> | 40 packages |
| 15 user accounts | | 3 user accounts |
| 12 open ports | | 2 open ports |
+------------------+ +------------------+
Think About It: Look at a server you manage. How many services are running that you do not actually use? Run
systemctl list-units --type=service --state=runningto find out. Could any of them be stopped and disabled?
User Security
Strong Password Policies
Weak passwords remain the number one cause of breaches. Linux gives you tools to enforce policy.
Setting Password Quality Requirements
On RHEL/Fedora systems, password quality is managed by pam_pwquality:
# View current password quality settings
sudo cat /etc/security/pwquality.conf
Key settings to configure:
# Edit password quality configuration
sudo vi /etc/security/pwquality.conf
# /etc/security/pwquality.conf
minlen = 12 # Minimum password length
dcredit = -1 # Require at least 1 digit
ucredit = -1 # Require at least 1 uppercase letter
lcredit = -1 # Require at least 1 lowercase letter
ocredit = -1 # Require at least 1 special character
maxrepeat = 3 # No more than 3 consecutive identical characters
reject_username # Cannot contain the username
enforce_for_root # Apply rules even when root sets passwords
Distro Note: On Debian/Ubuntu, install
libpam-pwqualityfirst:sudo apt install libpam-pwquality. The configuration file is the same.
Password Aging with chage
The chage command controls password expiration and aging policies.
# View password aging info for a user
sudo chage -l alice
Last password change : Jan 15, 2026
Password expires : Apr 15, 2026
Password inactive : May 15, 2026
Account expires : never
Minimum number of days between changes : 7
Maximum number of days between changes : 90
Number of days of warning before expiry : 14
# Set maximum password age to 90 days
sudo chage -M 90 alice
# Set minimum days between changes (prevents rapid cycling)
sudo chage -m 7 alice
# Set warning period to 14 days before expiry
sudo chage -W 14 alice
# Force password change on next login
sudo chage -d 0 alice
# Set account expiration date
sudo chage -E 2026-12-31 alice
# Set inactive period (days after expiry before account locks)
sudo chage -I 30 alice
Hands-On: Set Up a Secure User Account
# Create a user with secure defaults
sudo useradd -m -s /bin/bash -c "Alice Engineer" alice
# Set a password
sudo passwd alice
# Configure password aging
sudo chage -M 90 -m 7 -W 14 -I 30 alice
# Verify the settings
sudo chage -l alice
# Lock an account (prefix password hash with !)
sudo usermod -L alice
# Unlock the account
sudo usermod -U alice
Account Auditing
Regularly audit your user accounts:
# Find accounts with empty passwords (CRITICAL security issue)
sudo awk -F: '($2 == "") {print $1}' /etc/shadow
# Find accounts with UID 0 (root-equivalent access)
awk -F: '$3 == 0 {print $1}' /etc/passwd
# Find users who can log in (have a valid shell)
grep -v '/nologin\|/false' /etc/passwd
# Check for accounts that have never had a password set
sudo awk -F: '$2 == "!" || $2 == "!!" {print $1}' /etc/shadow
# List users in the sudo/wheel group
getent group sudo 2>/dev/null || getent group wheel
Think About It: Why is it dangerous to have more than one account with UID 0? (Hint: think about audit trails and accountability.)
File Security Review
Checking Permissions on Critical Files
Certain files must have strict permissions. If they do not, your system is vulnerable.
# /etc/shadow should be readable only by root
ls -l /etc/shadow
# Expected: -rw-r----- 1 root shadow
# /etc/passwd is world-readable (that is fine), but not writable
ls -l /etc/passwd
# Expected: -rw-r--r-- 1 root root
# SSH host keys must be root-only
ls -l /etc/ssh/ssh_host_*_key
# Expected: -rw------- 1 root root
# Home directories should not be world-readable
ls -ld /home/*
# Expected: drwx------ or drwxr-x---
# Cron directories
ls -ld /etc/cron.*
ls -l /etc/crontab
Finding World-Writable Files
World-writable files can be modified by any user -- a serious risk:
# Find world-writable files (excluding /proc and /sys)
find / -xdev -type f -perm -0002 -ls 2>/dev/null
# Find world-writable directories without sticky bit
find / -xdev -type d \( -perm -0002 -a ! -perm -1000 \) -ls 2>/dev/null
The sticky bit (t) on directories like /tmp prevents users from deleting
each other's files. Without it, any user could delete any other user's temp files.
Finding SUID and SGID Binaries
SUID (Set User ID) binaries run with the permissions of the file owner, typically root. A vulnerable SUID binary is a direct path to root access.
# Find all SUID binaries
find / -perm -4000 -type f 2>/dev/null
# Find all SGID binaries
find / -perm -2000 -type f 2>/dev/null
# Find both SUID and SGID
find / -perm /6000 -type f 2>/dev/null
Common legitimate SUID binaries include:
/usr/bin/passwd - Users need to update /etc/shadow (owned by root)
/usr/bin/sudo - Privilege escalation by design
/usr/bin/su - Switch user
/usr/bin/chsh - Change login shell
/usr/bin/mount - Mount filesystems (sometimes)
/usr/bin/ping - Raw socket access (on older systems)
Any SUID binary not on your expected list should be investigated. To remove the SUID bit from a binary you do not need:
# Remove SUID bit (be careful -- do not break system tools!)
sudo chmod u-s /path/to/suspicious/binary
WARNING: Never remove the SUID bit from
/usr/bin/sudoor/usr/bin/passwdunless you know exactly what you are doing. You will lock yourself out of privilege escalation and password changes.
Open Ports Audit
Every open port is a door. Know which doors are open and why.
# List all listening TCP and UDP ports with process info
sudo ss -tlnup
# Same information, different format
sudo netstat -tlnup # (install net-tools if not available)
Example output:
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1234))
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=5678))
LISTEN 0 128 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=9012))
LISTEN 0 128 0.0.0.0:3306 0.0.0.0:* users:(("mysqld",pid=3456))
Reading this output, ask yourself:
- Port 22 on 0.0.0.0 -- SSH is accessible from all interfaces. Is that intended? Should it be restricted to a management network?
- Port 80 on 0.0.0.0 -- Web server is publicly accessible. Expected for a web server.
- Port 5432 on 127.0.0.1 -- PostgreSQL listens only on localhost. Good -- it is not exposed to the network.
- Port 3306 on 0.0.0.0 -- MySQL is accessible from all interfaces. Does it need to be? If only local applications use it, bind it to 127.0.0.1.
Restricting a Service to Localhost
If a service should only accept local connections:
# For PostgreSQL, edit postgresql.conf
listen_addresses = 'localhost'
# For MySQL/MariaDB, edit my.cnf
bind-address = 127.0.0.1
# For Redis, edit redis.conf
bind 127.0.0.1
After changing the configuration, restart the service and verify:
sudo systemctl restart postgresql
sudo ss -tlnp | grep 5432
# Should show 127.0.0.1:5432, not 0.0.0.0:5432
Basic Hardening Checklist
Here is a practical hardening checklist you can walk through on any new server.
1. Update Everything
# Debian/Ubuntu
sudo apt update && sudo apt upgrade -y
# RHEL/Fedora
sudo dnf upgrade -y
2. Remove Unnecessary Packages and Services
# List running services
systemctl list-units --type=service --state=running
# Disable a service you do not need
sudo systemctl stop cups
sudo systemctl disable cups
# Remove packages you do not need
sudo apt remove --purge telnetd rsh-server # Debian/Ubuntu
sudo dnf remove telnet-server rsh-server # RHEL/Fedora
3. Configure SSH Securely
Edit /etc/ssh/sshd_config:
# Disable root login
PermitRootLogin no
# Disable password authentication (use keys only)
PasswordAuthentication no
# Use only SSH protocol 2
Protocol 2
# Limit SSH to specific users
AllowUsers alice bob
# Set idle timeout
ClientAliveInterval 300
ClientAliveCountMax 2
# Validate config before restarting
sudo sshd -t
# Restart SSH
sudo systemctl restart sshd
WARNING: Before disabling password authentication, make sure you have uploaded your SSH public key and tested key-based login. Otherwise you will lock yourself out.
4. Configure a Firewall
Use the firewall (covered in Chapter 34) to allow only needed ports:
# UFW (Ubuntu/Debian)
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
# firewalld (RHEL/Fedora)
sudo firewall-cmd --set-default-zone=drop
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
5. Set Up Proper Logging
Ensure system logging is working and logs are being rotated:
# Check if rsyslog or journald is running
systemctl status rsyslog
systemctl status systemd-journald
# Check log rotation configuration
cat /etc/logrotate.conf
ls /etc/logrotate.d/
fail2ban: Automated Intrusion Prevention
fail2ban monitors log files for failed authentication attempts and
automatically blocks offending IP addresses using firewall rules.
Installation
# Debian/Ubuntu
sudo apt install fail2ban
# RHEL/Fedora
sudo dnf install fail2ban
Configuration
Never edit the main config files directly. Create local overrides:
# Create a local jail configuration
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo vi /etc/fail2ban/jail.local
Key settings in jail.local:
[DEFAULT]
# Ban IP for 1 hour
bantime = 3600
# Time window for counting failures
findtime = 600
# Number of failures before ban
maxretry = 5
# Email notifications (optional)
# destemail = admin@example.com
# action = %(action_mwl)s
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
Distro Note: The log path differs between distributions. Debian/Ubuntu uses
/var/log/auth.log, while RHEL/Fedora uses/var/log/secure. Some newer setups using only journald may needbackend = systemdinstead.
Managing fail2ban
# Start and enable fail2ban
sudo systemctl start fail2ban
sudo systemctl enable fail2ban
# Check status of all jails
sudo fail2ban-client status
# Check status of SSH jail specifically
sudo fail2ban-client status sshd
Example output:
Status for the jail: sshd
|- Filter
| |- Currently failed: 2
| |- Total failed: 47
| `- File list: /var/log/auth.log
`- Actions
|- Currently banned: 1
|- Total banned: 5
`- Banned IP list: 203.0.113.42
# Manually unban an IP
sudo fail2ban-client set sshd unbanip 203.0.113.42
# Manually ban an IP
sudo fail2ban-client set sshd banip 198.51.100.10
# Check what fail2ban regex matches (useful for debugging)
sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf
Hands-On: Test fail2ban
# 1. Start fail2ban with SSH jail enabled
sudo systemctl start fail2ban
# 2. From another machine, intentionally fail SSH login 4+ times
ssh baduser@your-server # Enter wrong password repeatedly
# 3. Check that the IP was banned
sudo fail2ban-client status sshd
# 4. Check the iptables/nftables rules fail2ban created
sudo iptables -L f2b-sshd -n -v
Unattended Upgrades: Automatic Security Patches
One of the most impactful security measures is simply keeping your system patched. Unattended upgrades automate this for security updates.
Debian/Ubuntu
# Install unattended-upgrades
sudo apt install unattended-upgrades apt-listchanges
# Enable it
sudo dpkg-reconfigure -plow unattended-upgrades
The main configuration file:
sudo vi /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
// Uncomment the next line to include regular updates too
// "${distro_id}:${distro_codename}-updates";
};
// Automatically reboot if required (e.g., kernel updates)
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
// Email notification
Unattended-Upgrade::Mail "admin@example.com";
Unattended-Upgrade::MailReport "on-change";
// Remove unused dependencies
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Enable the automatic timer:
sudo vi /etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
# Test the configuration (dry run)
sudo unattended-upgrades --dry-run --debug
# Check the logs
cat /var/log/unattended-upgrades/unattended-upgrades.log
RHEL/Fedora
# Install dnf-automatic
sudo dnf install dnf-automatic
# Configure it
sudo vi /etc/dnf/automatic.conf
[commands]
# What to do: download, apply, or nothing
apply_updates = yes
# Only apply security updates
upgrade_type = security
[emitters]
# How to notify
emit_via = stdio
[email]
email_from = root@localhost
email_to = admin@example.com
# Enable the timer
sudo systemctl enable --now dnf-automatic.timer
# Verify the timer is active
systemctl status dnf-automatic.timer
Think About It: Should you enable automatic reboots on a production database server? What could go wrong? How might you handle the need for kernel updates that require a reboot on a system that cannot go down during business hours?
Debug This
A junior admin reports that users are complaining they cannot change their passwords. You investigate and find:
$ ls -l /usr/bin/passwd
-rwxr-xr-x 1 root root 68208 Mar 14 2025 /usr/bin/passwd
$ passwd
passwd: Authentication token manipulation error
passwd: password unchanged
What is wrong? (Hint: compare the permissions shown above with what the passwd
binary normally requires.)
Answer: The SUID bit is missing. The passwd command needs to run as root to
modify /etc/shadow, but the permissions show -rwxr-xr-x instead of
-rwsr-xr-x. Someone (or a misconfigured script) removed the SUID bit. Fix it
with:
sudo chmod u+s /usr/bin/passwd
Verify:
ls -l /usr/bin/passwd
# Should show: -rwsr-xr-x 1 root root 68208 ...
What Just Happened?
+------------------------------------------------------------------+
| LINUX SECURITY FUNDAMENTALS |
+------------------------------------------------------------------+
| |
| MINDSET: |
| - Defense in depth: multiple independent layers |
| - Least privilege: minimum necessary permissions |
| - Attack surface reduction: remove what you don't need |
| |
| USER SECURITY: |
| - Enforce password quality with pam_pwquality |
| - Control password aging with chage |
| - Audit accounts: empty passwords, extra UID 0, unused |
| |
| FILE SECURITY: |
| - Check permissions on /etc/shadow, /etc/passwd, SSH keys |
| - Find world-writable files and directories |
| - Audit SUID/SGID binaries -- know your expected list |
| |
| NETWORK SECURITY: |
| - Audit open ports with ss -tlnup |
| - Bind services to localhost when possible |
| - Firewall: default deny, allow only what you need |
| |
| AUTOMATED DEFENSE: |
| - fail2ban: auto-ban brute-force attackers |
| - unattended-upgrades / dnf-automatic: auto-patch |
| |
| HARDENING CHECKLIST: |
| 1. Update everything |
| 2. Remove unnecessary packages/services |
| 3. Configure SSH securely (keys, no root login) |
| 4. Enable firewall with default deny |
| 5. Set up logging and monitoring |
| 6. Enable fail2ban |
| 7. Enable automatic security updates |
| |
+------------------------------------------------------------------+
Try This
Exercise 1: Security Audit Script
Write a bash script that performs a basic security audit:
#!/bin/bash
echo "=== Security Audit Report ==="
echo "Date: $(date)"
echo ""
echo "--- Users with UID 0 ---"
awk -F: '$3 == 0 {print $1}' /etc/passwd
echo ""
echo "--- Accounts with empty passwords ---"
sudo awk -F: '($2 == "") {print $1}' /etc/shadow
echo ""
echo "--- SUID binaries ---"
find / -perm -4000 -type f 2>/dev/null
echo ""
echo "--- Listening ports ---"
ss -tlnp
echo ""
echo "--- Failed SSH logins (last 24h) ---"
sudo journalctl -u sshd --since "24 hours ago" | grep -c "Failed"
echo ""
echo "--- Packages needing updates ---"
apt list --upgradable 2>/dev/null | tail -n +2 | wc -l
Run it on your system and review the output.
Exercise 2: Harden a Fresh Server
Start with a fresh VM and apply the full hardening checklist from this chapter. Document every change you make. Then ask a colleague to try to find something you missed.
Exercise 3: fail2ban Custom Jail
Create a custom fail2ban jail for Nginx that bans IPs making too many 404 requests
(a common sign of vulnerability scanning). Hint: you will need to write a custom
filter in /etc/fail2ban/filter.d/.
Bonus Challenge
Set up a second VM and use it to attack your hardened server. Try brute-forcing SSH,
scanning ports with nmap, and accessing services. Document what your defenses
caught and what got through. This adversarial testing is how real security teams
validate their hardening.