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=running to 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-pwquality first: 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/sudo or /usr/bin/passwd unless 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 need backend = systemd instead.

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.