Linux Security & Hardening
Table of Contents
SSH hardening
SSH is one of the most targeted services on any server exposed to a network. Hardening SSH reduces the attack surface, limits brute-force attempts, and enforces stronger authentication and cryptography.
Core Configuration
/etc/ssh/sshd_config
1# disable legacy, and use modern protocol
2Protocol 2
3
4# disable direct root login
5PermitRootLogin no
6
7# use only key-bared authentication
8PasswordAuthentication no
9AuthenticationMethods publickey
10
11# restrict allowed users
12AllowUsers user1 user2
13
14# change default port
15Port 2222
16
17# enable Multi-Factor Authentication
18AuthenticationMethods publickey,keyboard-interactive
19
20# allow only strong ciphers
21Ciphers aes256-ctr,aes192-ctr,aes128-ctr
22
23# limit concurrent sessions
24MaxSessions 3
25
26# automaticalyy disconnect ssh sessions
27ClientAliveInterval 300
28ClientAliveCountMax 0
Enforce password complexity
Debian base
/etc/pam.d/common-password
1password requisite pam_pwquality.so retry=3 minlen=8
Redhat base
/etc/security/pwquality.conf
1minlen = 8
Monitoring
Regularly inspect SSH logs to detect suspicious behavior. Common log locations:
- /var/log/auth.log
- journalctl -u ssh
Optional Enhancement
- Separate SSH acces to internal network only
- Restrict SSH acces by IP through firewall rules
- Update openssh package regularly
Fail2ban
Fail2Ban is a log-monitoring intrusion prevention tool that watches log files for suspicious behavior (like repeated failed logins) and automatically blocks offending IPs by updating firewall rules.
- Protects against brute-force attacks (e.g., SSH login attempts).
- Works even if password auth is disabled (it still reduces noise and resource waste).
- Blocks bad actors automatically with customizable actions.
Installation
1apt install fail2ban
Configuration
Fail2ban ships with default config, copy them so that we won’t overwrite the default config.
1cp /etc/fail2ban/fail2ban.conf /etc/fail2ban/fail2ban.local
2cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
First make sure to white list your IP address (public and priavte). /etc/fail2ban/jail.local
1ignoreip = 127.0.0.1/8 ::1 192.168.1.10 203.0.113.45 10.0.0.0/8 192.168.1.0/24
Enable Extended Banning, this watches fail2ban logs /var/log/fail2ban.log for recurring offebses. Enabling this can significantly increases security against persistent brute-force attacks.
/etc/fail2ban/jail.local
1[recidive]
2enabled = true
3logpath = /var/log/fail2ban.log
4banaction = %(banaction_allports)s
5bantime = 1w
6findtime = 1d
Restart service after making changes.
1systemctl restart fail2ban
Status
To show active jails.
1fail2ban-client status
Check details for sshd.
1fail2ban-client status sshd
You can also ehecked from iptables, rules automatically created by fail2ban.
1iptables -n -L
Unbanning IP
Let’s say you got banned for stupid reason like I did. You can login with other IP or maybe use your phone, and then unban your IP.
1fail2ban-client set <jailname> unbanip <IP_ADDRESS>
2
3# examples
4fail2ban-client set sshd unbanip 1.2.3.4
5fail2ban-client set recidive unbanip 1.2.3.4
Better yet, install VPN to your server and white list the subnet used.
Custom Config/Regex
You can alse create custom config to match regex on an application logs. This config is from my docker mail.
Add filter config in /etc/fail2ban/filter.d.
mailmoto.conf
1[Definition]
2
3failregex = ^.*imap-login: Info: Disconnected: Connection closed \(auth failed.*\): user=<.*>, method=.*?, rip=<HOST>,.*
4 ^.*postfix/smtpd\[.*\]: warning: .*?\[<HOST>\]: SASL .* authentication failed:.*
5
6ignoreregex =
Enable this filter in /etc/fail2ban/jail.d
mailmoto.local
1[mailmoto]
2enabled = true
3filter = mailmoto
4logpath = /srv/volume/mailmoto/log/mail.log
5
6port = smtp,submission,imaps
7
8maxretry = 3
9findtime = 10m
10bantime = 1h
Test filter if it will match.
1fail2ban-regex /srv/volume/mailmoto/log/mail.log /etc/fail2ban/filter.d/mailmoto.conf
SELinux
SELinux (Security-Enhanced Linux) is a mandatory access control (MAC) system built into the Linux kernel.
Every process, file, port, and other system object has a security label that includes a type. Policy rules determine what each process type is allowed to do with each object type.
Unlike traditional Linux permissions (owner/group + rwx), SELinux does not depend on UIDs — it enforces access based on these labels. :contentReference[oaicite:1]{index=1}
Mode Definitions
| Mode | Definition | Behavior | Use Case |
|---|---|---|---|
| Enforcing | SELinux actively enforces policy rules | Denies access that violates policy; logs AVC denials | Production systems; full security enforcement |
| Permissive | SELinux logs policy violations but does not block them | Access that would normally be denied is allowed; AVC denials are still recorded | Troubleshooting, debugging, testing policies |
| Disabled | SELinux is completely turned off | No policy enforcement; no logging | Rarely used; system completely unprotected by SELinux |
| Mode | Policy enforced? | Access denied? | AVC logged? |
|---|---|---|---|
| Enforcing | ✅ Yes | ✅ Yes | ✅ Yes |
| Permissive | ✅ Yes | ❌ No | ✅ Yes |
| Disabled | ❌ No | ❌ No | ❌ No |
Labels and Policy
- Each process runs in a domain (a type) — e.g., the SSH daemon (
sshd) runs in thesshd_tdomain, and the Apache web server runs in thehttpd_tdomain. - Each file or directory has a type label — e.g., web content might be labeled
httpd_sys_content_t. - SELinux policy rules (
allowstatements) explicitly state which domains can access which object types. :contentReference[oaicite:2]{index=2}
All access is denied by default until a rule specifically allows it.
You can check this using command ls -Z.
1$ ls -lZ
2total 24
3dr-xr-xr-x. 1 root root system_u:object_r:mnt_t:s0 0 Jul 30 2025 afs
4lrwxrwxrwx. 1 root root system_u:object_r:bin_t:s0 7 Jul 30 2025 bin -> usr/bin
5drwxr-xr-x. 6 root root system_u:object_r:boot_t:s0 4096 Oct 23 03:53 boot
6-rw-rw-r--. 1 root root system_u:object_r:etc_runtime_t:s0 71 Oct 23 03:52 config.partids
7drwxr-xr-x 18 root root ? 3740 Feb 3 05:17 dev
8drwxr-xr-x. 1 root root system_u:object_r:etc_t:s0 2660 Jan 31 07:01 etc
9drwxrwxr-x. 1 root root system_u:object_r:default_t:s0 10 Oct 23 03:53 grub2
10drwxr-xr-x. 1 root root system_u:object_r:home_root_t:s0 38 Jan 31 07:00 home
11lrwxrwxrwx. 1 root root system_u:object_r:lib_t:s0 7 Jul 30 2025 lib -> usr/lib
12lrwxrwxrwx. 1 root root system_u:object_r:lib_t:s0 9 Jul 30 2025 lib64 -> usr/lib64
13drwxr-xr-x. 1 root root system_u:object_r:mnt_t:s0 0 Jul 30 2025 media
14drwxr-xr-x. 1 root root system_u:object_r:mnt_t:s0 0 Jul 30 2025 mnt
15drwxr-xr-x. 1 root root system_u:object_r:usr_t:s0 0 Jul 30 2025 opt
16dr-xr-xr-x 207 root root ? 0 Feb 3 05:17 proc
17dr-xr-x---. 1 root root system_u:object_r:admin_home_t:s0 246 Feb 3 05:17 root
18drwxr-xr-x 36 root root ? 900 Feb 3 05:17 run
19lrwxrwxrwx. 1 root root system_u:object_r:bin_t:s0 8 Jul 30 2025 sbin -> usr/sbin
20drwxr-xr-x. 1 root root unconfined_u:object_r:default_t:s0 0 Jan 31 07:16 sftp
21drwxr-xr-x. 1 root root system_u:object_r:var_t:s0 8 Jan 31 07:01 srv
22dr-xr-xr-x 13 root root ? 0 Feb 3 09:18 sys
23drwxrwxrwt 10 root root ? 200 Feb 3 05:57 tmp
24drwxr-xr-x. 1 root root system_u:object_r:usr_t:s0 100 Oct 23 03:50 usr
25drwxr-xr-x. 1 root root system_u:object_r:var_t:s0 170 Oct 23 03:52 var
Type Enforcement
- The
httpd_tdomain is allowed to read and serve files labeledhttpd_sys_content_t. - The
sshd_tdomain is allowed to read SSH configs and manage SSH sessions. - Without explicit rules, processes can’t cross boundaries — e.g.,
httpd_tcan’t read SSH host keys just because Unix file permissions allow it. :contentReference[oaicite:3]{index=3} - Even if sshd is compromised, it cannot suddenly start serving web pages or reading Apache files, because SELinux doesn’t allow sshd_t to do what httpd_t does.
- In short, SELinux doesn’t just care who you are (user/group)—it cares what you are (sshd, httpd, etc.) and enforces behavior based on that role. Each service is boxed into its own lane, and crossing lanes is blocked unless there’s an explicit rule allowing it.
MCS Enforement
MCS (Multi-Category Security) is used to isolate processes that share the same SELinux type so they cannot access each other’s data. MCS does not create a hierarchy. Access is allowed only when category labels match exactly.
To further explain this, let’s assume we have httpd running two website. same service, same type, almost the same labels and run on the same system but must be isolated from each other.
Type enforcement alone is not enough because:
- Both sites run as
httpd_t - Both serve web content
- Both need similar permissions
MCS adds category labels to separate them. Here is a scenario of two websites on one httpd server.
- Website A →
site_a - Website B →
site_b
Both run at level s0, but with different categories c1 and c2.
Process Context
1httpd → system_u:system_r:httpd_t:s0
File Lables
1/var/www/site_a → system_u:object_r:httpd_sys_content_t:s0:c1
2/var/www/site_b → system_u:object_r:httpd_sys_content_t:s0:c2
The labels are almost identical:
- Same user
- Same role
- Same type
- Same level (
s0) - Only the category differs
MCS enforcement allowed httpd to read files in site_a labeled c1, and site_b labeled c2.
Basically, httpd cans server site_a when operating in context s0:c1, httpd cans server site_b when operating in context s0:c2. Apache cannot read c1 content while operating as c2, and vice versa. SeLinux treats this as a separate httpd instance, even though it’s the same binary.
To have give more context on this subject, MCS enforement is commonly used for:
- Containers
- Virtual Machines
- Sandboxed services
- Multi-tenant systems
MLS Enforcement
MLS (Multi-Level Security) enforces who can access higher or lower classified data. MLS has hierchy and rules follow clerance leves. So even if the system is compromised, how far up or down are you allowed to see.
Core MLS rules (Bell–LaPadula):
- No read up
- No write down
To demostrate this, let’s assume three VMs with different trust levels
| Level | Meaning |
|---|---|
s0 |
Public |
s1 |
Internal |
s2 |
Secret |
VM labeling (host-enforced). Each VM’s disk, memory, and devices are labeled at the same level.
1VM Public → system_u:system_r:virt_t:s0
2VM Internal → system_u:system_r:virt_t:s1
3VM Secret → system_u:system_r:virt_t:s2
Scenario: VM Internal is compromised, attacker gains root access insite VM Internal (s1). Using the rule set by MLS (no read up, no write up), the attacker read and write access:
| Read | Write | |
|---|---|---|
s0 |
yes | yes |
s1 |
yes | no |
s2 |
no | no |
SELinux Enforcement Comparison
| Enforcement | What it Controls | Core Question It Answers | Example |
|---|---|---|---|
| DAC (Linux perms) | Users & groups | Does this user have rwx permission? | root can read /etc/shadow |
| Type Enforcement (TE) | Process ↔ object types | Is this service allowed to access this kind of object? | httpd_t can read httpd_sys_content_t |
| MCS | Category isolation | Is this the correct instance/workload? | Website A (c1) cannot read Website B (c2) |
| MLS | Security levels | Is this subject trusted enough? | s1 cannot read s2 (no read up) |
Configuration
To change SELinux mode.
1# set to enforcing
2setenforce 1
3
4# set to permissive
5setenforce 0
6```bash
7To fully disable SELinux edit */etc/selinux/config*, and set SELINUX to disabled. Permissive and Enforcing can also be set using this method.
disabled, permissive, enforcing
SELINUX=disabled
1To check for status
2
3`getenforce`
4
5#### Example
6To understand (fully maybe? well this is my note for my dumb self) SELinux, let's enforce service like httpd.
7
8Install and start service.
9```bash
10$ dnf install httpd
11
12$ systemctl enable --now httpd
13$ systemctl status httpd
14
15● httpd.service - The Apache HTTP Server
16 Loaded: loaded (/usr/lib/systemd/system/httpd.service; enabled; preset: disabled)
17 Drop-In: /usr/lib/systemd/system/service.d
18 └─10-timeout-abort.conf
19 Active: active (running) since Tue 2026-02-03 12:02:57 UTC; 4s ago
20 Invocation: 263a11e3e94149379fb7cd6db909c0f9
21 Docs: man:httpd.service(8)
22 Main PID: 1268 (httpd)
23 Status: "Started, listening on: port 80"
24 Tasks: 177 (limit: 4617)
25 Memory: 14.2M (peak: 14.2M)
26 CPU: 113ms
27 CGroup: /system.slice/httpd.service
28 ├─1268 /usr/sbin/httpd -DFOREGROUND
29 ├─1269 /usr/sbin/httpd -DFOREGROUND
30 ├─1270 /usr/sbin/httpd -DFOREGROUND
31 ├─1271 /usr/sbin/httpd -DFOREGROUND
32 └─1272 /usr/sbin/httpd -DFOREGROUND
Default root documentn will be at /var/www/html. Let’ create index.html and check the security context of the file.
index.html
1<html>
2<h3>My website</h3> <!-- -->
3<p>Look at me Mom I'm a DevOps.</p>
1$ ls -lZ /var/www/html/
2total 4
3-rw-r--r--. 1 root root unconfined_u:object_r:httpd_sys_content_t:s0 73 Feb 3 12:07 index.html
The httpd_sys_content_t is the default allowed file type for the httpd process. If a file or directory has this file type in its SELinux security context, the httpd process can access it.
Now let’s create a new html file, navigate to /root and create a test.html. Then move the file to document root /var/www/html.
1$ cd /root
2$ cat /var/www/html/index.html > test.html
3$ cat test.html
4<html>
5<h3>My website</h3> <!-- -->
6<p>Look at me Mom I'm a DevOps.</p>
7$ mv test.html /var/www/html/
8$ ls -lZ
9total 8
10-rw-r--r--. 1 root root unconfined_u:object_r:httpd_sys_content_t:s0 73 Feb 3 12:07 index.html
11-rw-r--r--. 1 root root unconfined_u:object_r:admin_home_t:s0 73 Feb 3 12:28 test.html
We can that the file keep its old label, type is still admin_home_t. If we curl the page we get:
1$ curl localhost/test.html
2<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
3<html><head>
4<title>403 Forbidden</title>
5</head><body>
6<h1>Forbidden</h1>
7<p>You don't have permission to access this resource.</p>
8</body></html>
But if we copy the file, security context are updated to httpd_sys_content_. This can also easily fix with restorecon command.
1$ restorecon -R /var/www/html
2$ ls -lZ
3total 8
4-rw-r--r--. 1 root root unconfined_u:object_r:httpd_sys_content_t:s0 73 Feb 3 12:07 index.html
5-rw-r--r--. 1 root root unconfined_u:object_r:httpd_sys_content_t:s0 73 Feb 3 12:28 test.html
Now we change the custom root document of httpd, copy /var/www/html to /srv/web. Edit httpd config and change document root to /srv/web.
1$ mkdir -p /srv/web
2$ cp -r /var/www/html/ /srv/web
3$ vim /etc/httpd/conf/httpd.conf
If we use restorecon in the new directory notice that no change would apply.
1$ restorecon -R /srv/web/
2$ ls -lZ
3total 8
4-rw-r--r--. 1 root root unconfined_u:object_r:var_t:s0 73 Feb 3 13:04 index.html
5-rw-r--r--. 1 root root unconfined_u:object_r:var_t:s0 73 Feb 3 13:04 test.html
To fix this issue, we need to update the SELinux database. The following command attaches the file type httpd_sys_content_t to the /srv/web directory. If command is missing, install policycoreutils-python-utils package.
1$ semanage fcontext -a -t httpd_sys_content_t "/srv/web(/.*)?"
2$ restorecon -Rv /srv/web
3$ ls -lZ
4total 8
5-rw-r--r--. 1 root root unconfined_u:object_r:httpd_sys_content_t:s0 73 Feb 3 13:04 index.html
6-rw-r--r--. 1 root root unconfined_u:object_r:httpd_sys_content_t:s0 73 Feb 3 13:04 test.html
Now to check we can curl the website.
1$ curl localhost/test.html
2<html>
3<h3>My website</h3> <!-- -->
4<p>Look at me Mom I'm a DevOps.</p>
We can also change the default port for httpd. Edit /etc/httpd/conf/httpd.conf and change listening port to Listen 8080. Check existing http port.
1$ semanage port -l | grep http
2http_cache_port_t tcp 8080, 8118, 8123, 10001-10010
3http_cache_port_t udp 3130
4http_port_t tcp 80, 81, 443, 488, 8008, 8009, 8443, 9000
5http_port_t udp 80, 443
6pegasus_http_port_t tcp 5988
7pegasus_https_port_t tcp 5989
Now we change the default port to 8080 and restart httpd service
1semanage port -m -t http_port_t -p tcp 8080
2systemctl restart httpd
Verify.
1$ curl http://localhost:8080
2<html>
3<h3>My website</h3> <!-- -->
4<p>Look at me Mom I'm a DevOps.</p>
Firewall
Automatic Security Upgrade
Debian base
Install auto update package
1apt install unattended-upgrades
Configure unattended-upgrades to security only. /etc/apt/apt.conf.d/50-unattended-upgrades
1Unattended-Upgrade::Allowed-Origins {
2 "${distro_id}:${distro_codename}-security";
3};
4Unattended-Upgrade::Automatic-Reboot "true";
5Unattended-Upgrade::Remove-Unused-Dependencies "true";
Enable periodic unattended-upgrades /etc/apt/apt.conf.d/20auto-upgrades
1APT::Periodic::Update-Package-Lists "1";
2APT::Periodic::Download-Upgradeable-Packages "1";
3APT::Periodic::AutocleanInterval "7";
4APT::Periodic::Unattended-Upgrade "1";
Redhat base
Configure dnf-automatic for security updates. /etc/dnf/automatic.conf
1upgrade_type = security
Enable service.
1systemctl enable --now dnf-automatic.timer
Audit
Install package.
1apt install auditd
2# or redhat base
3dnf isntall auditd
Add autdit rules. /etc/audit/rules.d/audit.rules
1-w /etc/passwd -p wa -k passwd_changes
2-w /etc/shadow -p wa -k shadow_changes
3-w /etc/group -p wa -k group_changes
4-w /etc/sudoers -p wa -k sudoers_changes
5
6# sudo
7-w /etc/sudoers -p wa -k sudo
8-w /etc/sudoers.d/ -p wa -k sudo
9
10# ssh
11-w /etc/ssh/sshd_config -p wa -k ssh
12
13# time changes
14-a always,exit -F arch=b64 -S adjtimex,settimeofday -k time_change
15-w /etc/localtime -p wa -k time_change
16
17# kernel modules
18-a always,exit -F arch=b64 -S init_module,finit_module,delete_module -k modules
Reload auditd rules
1auditctl -R /etc/audit/audit.rules
Advanced Intrusion Detection Environment
It monitors file attributes—such as permissions, size, and cryptographic hashes—to detect unauthorized modifications, malware, or rootkits, acting as a host-based intrusion detection system.
Install package.
1apt install aide
Initialize AIDE database
1aide --init
2aide --init --config /etc/aide/aide.conf