You deploy a fresh Ubuntu server, configure your application, open the necessary ports, and call it done. The machine is “in production.”

That machine is running with a security posture designed for convenience, not defense.

Default Ubuntu ships with services you don’t need, kernel parameters tuned for compatibility rather than security, and filesystem mount options that allow code execution in places it should never happen. CIS Benchmarks exist to fix this systematically. In this article, we’ll walk through hardening Ubuntu using CIS controls — automated with Ansible, verified with auditing tools.

CIS Benchmarks: What They Are and Why They Matter

The Center for Internet Security (CIS) publishes detailed hardening guides for operating systems, cloud platforms, databases, and network devices. The Ubuntu CIS Benchmark is a 300+ page document with specific, testable recommendations for locking down a system.

Each recommendation is categorized into two levels:

LevelTargetImpactExample
Level 1Any server, minimal performance impactLow risk of breaking functionalityDisable unused filesystems, configure password policies
Level 2High-security environmentsMay reduce functionality or increase operational overheadRestrict access to kernel logs, enforce detailed auditing

Level 1 is the baseline you should apply everywhere. Level 2 is for systems handling sensitive data — PCI-DSS, HIPAA, or anything with regulatory requirements.

CIS Benchmarks are consensus-driven. They represent the minimum hardening that the security community agrees on. If your system doesn’t pass Level 1, you have work to do.

Ansible Automation: The ansible-lockdown Project

Manually applying 200+ CIS controls across a fleet of servers is not realistic. The ansible-lockdown project maintains Ansible roles that implement CIS Benchmarks for major operating systems, including Ubuntu.

Getting Started

1
2
3
4
5
6
# Clone the Ubuntu 22.04 CIS role
git clone https://github.com/ansible-lockdown/UBUNTU22-CIS.git
cd UBUNTU22-CIS

# Install dependencies
ansible-galaxy install -r requirements.yml

Playbook Structure

Create a playbook that applies the role with your customizations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# harden.yml
---
- name: Apply CIS Benchmark to Ubuntu servers
  hosts: production_servers
  become: true
  roles:
    - role: UBUNTU22-CIS
      vars:
        # Level 1 controls — apply everything
        ubtu22cis_level_1: true
        # Level 2 controls — enable selectively
        ubtu22cis_level_2: true

        # Skip controls that break your specific workload
        ubtu22cis_rule_1_1_1_1: false   # Skip cramfs disable if needed
        ubtu22cis_rule_5_3_4: true      # Enforce SSH idle timeout

        # Customize password policy
        ubtu22cis_pass_max_days: 90
        ubtu22cis_pass_min_days: 7
        ubtu22cis_pass_warn_age: 14

Running the Playbook

1
2
3
4
5
# Dry run first — always
ansible-playbook harden.yml --check --diff

# Apply for real
ansible-playbook harden.yml
Never run CIS hardening blindly in production. Always use --check --diff first, review the changes, and test on a staging system. Some controls will break applications that depend on permissive defaults.

Removing and Disabling Unnecessary Services

Every running service is attack surface. A default Ubuntu installation includes services that have no business running on a production server.

Services to Disable

ServiceWhat It DoesWhy Disable It
avahi-daemonmDNS/DNS-SD service discoveryExposes the host on the local network, useless on servers
cupsPrint serverYou’re not printing from a production server
bluetoothBluetooth stackNo server needs Bluetooth
rpcbindRPC port mapper for NFSUnless you’re running NFS, this is an open door
whoopsieUbuntu error reportingSends crash data to Canonical — not appropriate for production
apportCrash report generatorSame as above

Ansible Task to Disable Services

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# roles/hardening/tasks/services.yml
- name: Disable unnecessary services
  ansible.builtin.systemd:
    name: "{{ item }}"
    state: stopped
    enabled: false
    masked: true
  loop:
    - avahi-daemon
    - cups
    - cups-browsed
    - bluetooth
    - rpcbind
    - whoopsie
    - apport
  failed_when: false  # Don't fail if the service doesn't exist

- name: Remove unnecessary packages
  ansible.builtin.apt:
    name:
      - avahi-daemon
      - cups
      - bluetooth
      - rpcbind
      - whoopsie
      - apport
    state: absent
    purge: true

The masked: true parameter is critical — it prevents the service from being started even by dependency resolution. Merely disabling a service is not enough if another package pulls it back in.

Automatic Security Patching with unattended-upgrades

The time between a CVE being published and an exploit appearing in the wild is shrinking. In 2025, the median time-to-exploit for critical vulnerabilities dropped below 72 hours. Manual patching schedules cannot keep up.

Installation and Configuration

1
sudo apt install unattended-upgrades apt-listchanges

Configure /etc/apt/apt.conf.d/50unattended-upgrades:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Only install security updates — no feature upgrades
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-security";
    "${distro_id}ESMApps:${distro_codename}-apps-security";
    "${distro_id}ESM:${distro_codename}-infra-security";
};

// Remove unused kernel packages after update
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";

// Remove unused dependencies
Unattended-Upgrade::Remove-Unused-Dependencies "true";

// Automatic reboot if required (kernel updates)
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";

// Email notification
Unattended-Upgrade::Mail "admin@yourdomain.com";
Unattended-Upgrade::MailReport "on-change";

Enable the automatic update timer in /etc/apt/apt.conf.d/20auto-upgrades:

1
2
3
4
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";

Reboot Strategy

Kernel updates require a reboot to take effect. The configuration above schedules reboots at 03:00, but in production you need coordination:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Ansible task for controlled reboot strategy
- name: Configure unattended-upgrades reboot window
  ansible.builtin.lineinfile:
    path: /etc/apt/apt.conf.d/50unattended-upgrades
    regexp: 'Unattended-Upgrade::Automatic-Reboot-Time'
    line: 'Unattended-Upgrade::Automatic-Reboot-Time "03:00";'

- name: Check if reboot is required
  ansible.builtin.stat:
    path: /var/run/reboot-required
  register: reboot_required

- name: Reboot if required (during maintenance window)
  ansible.builtin.reboot:
    reboot_timeout: 300
  when: reboot_required.stat.exists

Filesystem Hardening

Default mount options allow code execution from temporary directories — a common technique in post-exploitation. CIS Benchmark Section 1.1 addresses this directly.

Restricting /tmp, /var/tmp, and /dev/shm

Edit /etc/fstab to add restrictive mount options:

1
2
3
4
# /etc/fstab — hardened mount options
tmpfs   /tmp        tmpfs   defaults,rw,nosuid,nodev,noexec,relatime,size=2G   0 0
tmpfs   /var/tmp    tmpfs   defaults,rw,nosuid,nodev,noexec,relatime,size=1G   0 0
tmpfs   /dev/shm    tmpfs   defaults,rw,nosuid,nodev,noexec,relatime,size=1G   0 0
OptionWhat It Does
noexecPrevents execution of binaries — blocks attackers from running payloads dropped in /tmp
nosuidIgnores setuid/setgid bits — prevents privilege escalation via temp files
nodevPrevents creation of device files — blocks device-based attacks

Ansible Task

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
- name: Harden /tmp mount options
  ansible.posix.mount:
    path: /tmp
    src: tmpfs
    fstype: tmpfs
    opts: defaults,rw,nosuid,nodev,noexec,relatime,size=2G
    state: mounted

- name: Harden /dev/shm mount options
  ansible.posix.mount:
    path: /dev/shm
    src: tmpfs
    fstype: tmpfs
    opts: defaults,rw,nosuid,nodev,noexec,relatime,size=1G
    state: mounted

Apply immediately without reboot:

1
2
sudo mount -o remount,noexec,nosuid,nodev /tmp
sudo mount -o remount,noexec,nosuid,nodev /dev/shm

Kernel Hardening via sysctl

The Linux kernel exposes tunable parameters through /proc/sys/ that control networking behavior, memory protections, and access restrictions. The defaults are permissive.

Critical sysctl Settings

Create /etc/sysctl.d/99-cis-hardening.conf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# --- Network hardening ---
# Disable IP forwarding (this is a server, not a router)
net.ipv4.ip_forward = 0
net.ipv6.conf.all.forwarding = 0

# Ignore ICMP redirects (prevents MITM via route injection)
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

# Don't send ICMP redirects
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0

# Enable SYN cookies (mitigate SYN flood attacks)
net.ipv4.tcp_syncookies = 1

# Log martian packets (packets with impossible source addresses)
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1

# Ignore ICMP broadcast requests (prevent Smurf attacks)
net.ipv4.icmp_echo_ignore_broadcasts = 1

# Reject source-routed packets
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0

# Enable reverse path filtering (anti-spoofing)
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# --- Kernel hardening ---
# Restrict access to dmesg (kernel ring buffer)
kernel.dmesg_restrict = 1

# Restrict access to kernel pointers in /proc
kernel.kptr_restrict = 2

# Disable SysRq key (prevents local console attacks)
kernel.sysrq = 0

# Enable ASLR (Address Space Layout Randomization) — full randomization
kernel.randomize_va_space = 2

# Restrict unprivileged access to eBPF
kernel.unprivileged_bpf_disabled = 1

# Restrict ptrace to direct child processes only
kernel.yama.ptrace_scope = 1

# --- Filesystem hardening ---
# Restrict creation of hard links and symlinks
fs.protected_hardlinks = 1
fs.protected_symlinks = 1

Apply immediately:

1
sudo sysctl --system

Why Each Setting Matters

Disabling IP forwarding prevents an attacker from turning your compromised server into a pivot point. Ignoring ICMP redirects blocks a classic man-in-the-middle vector. Restricting dmesg and kptr_restrict hides kernel memory layout information that aids exploit development — as noted in kernel security research from CIQ and ARMO, privilege escalation via kernel flaws remains a top threat vector in 2025-2026.

AppArmor Enforcement

AppArmor is Ubuntu’s mandatory access control system. It confines programs to a limited set of resources — files, network access, capabilities — based on per-application profiles.

Verify AppArmor Status

1
2
3
sudo apparmor_status
# Or:
sudo aa-status

You should see profiles loaded and in enforce mode, not complain mode.

Switch Profiles from Complain to Enforce

1
2
3
4
5
# List profiles in complain mode
sudo aa-status | grep complain

# Enforce all profiles
sudo aa-enforce /etc/apparmor.d/*

Key AppArmor Tasks via Ansible

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
- name: Ensure AppArmor is enabled at boot
  ansible.builtin.lineinfile:
    path: /etc/default/grub
    regexp: '^GRUB_CMDLINE_LINUX='
    line: 'GRUB_CMDLINE_LINUX="apparmor=1 security=apparmor"'
  notify: Update GRUB

- name: Install AppArmor utilities
  ansible.builtin.apt:
    name:
      - apparmor
      - apparmor-utils
      - apparmor-profiles
      - apparmor-profiles-extra
    state: present

- name: Enforce all AppArmor profiles
  ansible.builtin.shell: aa-enforce /etc/apparmor.d/*
  changed_when: false
AppArmor vs SELinux: Ubuntu ships with AppArmor by default. Both provide mandatory access control. AppArmor uses path-based rules (easier to write), SELinux uses label-based rules (more granular). Use whichever your distribution supports natively — the important thing is that MAC is active and enforcing.

User Account Hardening

Weak user account policies are one of the most exploited vectors. CIS Benchmark Section 5 covers this extensively.

Password Policies in /etc/login.defs

1
2
3
4
5
6
7
8
# /etc/login.defs — hardened values
PASS_MAX_DAYS   90      # Force password rotation every 90 days
PASS_MIN_DAYS   7       # Prevent immediate re-change (stops cycling)
PASS_WARN_AGE   14      # Warn users 14 days before expiry
PASS_MIN_LEN    14      # Minimum password length
LOGIN_RETRIES   3       # Lock after 3 failed attempts
LOGIN_TIMEOUT   60      # Timeout for login prompt
ENCRYPT_METHOD  SHA512  # Strong hashing for /etc/shadow

PAM Configuration for Password Quality

Install and configure pam_pwquality:

1
sudo apt install libpam-pwquality

Edit /etc/security/pwquality.conf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Minimum password length
minlen = 14
# Minimum character classes (uppercase, lowercase, digit, special)
minclass = 4
# Maximum consecutive identical characters
maxrepeat = 3
# Maximum consecutive characters from the same class
maxclassrepeat = 4
# Reject passwords containing the username
usercheck = 1
# Enforce checks against dictionary words
dictcheck = 1
# Number of changed characters from old password
difok = 8

Account Lockout via PAM

Configure /etc/pam.d/common-auth to lock accounts after failed attempts:

1
2
3
# Add before other auth lines
auth required pam_faillock.so preauth silent audit deny=5 unlock_time=900
auth [default=die] pam_faillock.so authfail audit deny=5 unlock_time=900

This locks an account for 15 minutes (900 seconds) after 5 failed login attempts.

Ansible Task for User Hardening

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
- name: Configure password aging in login.defs
  ansible.builtin.lineinfile:
    path: /etc/login.defs
    regexp: "^{{ item.key }}"
    line: "{{ item.key }}   {{ item.value }}"
  loop:
    - { key: "PASS_MAX_DAYS", value: "90" }
    - { key: "PASS_MIN_DAYS", value: "7" }
    - { key: "PASS_WARN_AGE", value: "14" }
    - { key: "ENCRYPT_METHOD", value: "SHA512" }

- name: Ensure no accounts have empty passwords
  ansible.builtin.shell: >
    awk -F: '($2 == "" ) { print $1 }' /etc/shadow
  register: empty_passwords
  changed_when: false

- name: Fail if empty passwords found
  ansible.builtin.fail:
    msg: "Accounts with empty passwords found: {{ empty_passwords.stdout }}"
  when: empty_passwords.stdout | length > 0

Auditing Your Hardening: OpenSCAP and Lynis

Hardening without verification is just hope. Two tools are essential for validating that your controls are actually applied.

OpenSCAP

OpenSCAP is the reference implementation for SCAP (Security Content Automation Protocol). It can evaluate your system against CIS Benchmarks and produce detailed compliance reports.

1
2
3
4
5
6
7
8
9
# Install OpenSCAP
sudo apt install libopenscap8 ssg-debderived ssg-ubuntu

# Run a CIS Benchmark evaluation
sudo oscap xccdf eval \
  --profile xccdf_org.ssgproject.content_profile_cis_level1_server \
  --results /tmp/cis-results.xml \
  --report /tmp/cis-report.html \
  /usr/share/xml/scap/ssg/content/ssg-ubuntu2204-ds.xml

The HTML report shows each CIS control, whether it passed or failed, and remediation instructions for failures.

Lynis

Lynis is a lightweight, agentless auditing tool that performs a comprehensive security scan:

1
2
3
4
5
# Install Lynis
sudo apt install lynis

# Run a full system audit
sudo lynis audit system

Lynis produces a hardening index (0-100) and categorized findings:

  Lynis security scan details:

  Hardening index : 82 [################    ]
  Tests performed : 268
  Plugins enabled : 2

  - Warnings (3):
    * Found one or more vulnerable packages [PKGS-7392]
    * No password set for single user mode [AUTH-9308]
    * Nameserver 127.0.0.53 does not respond [NETW-2705]

  - Suggestions (18):
    * Consider hardening SSH configuration [SSH-7408]
    * Install a file integrity tool [FINT-4350]
    * Enable process accounting [ACCT-9622]

Ansible Task to Schedule Audits

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
- name: Install auditing tools
  ansible.builtin.apt:
    name:
      - lynis
      - libopenscap8
    state: present

- name: Create weekly Lynis audit cron job
  ansible.builtin.cron:
    name: "Weekly Lynis security audit"
    minute: "0"
    hour: "2"
    weekday: "0"
    job: "/usr/sbin/lynis audit system --cronjob --quiet > /var/log/lynis-audit.log 2>&1"
    user: root

Putting It All Together

Here’s the order of operations for hardening a new Ubuntu server:

  1. Apply OS updatesapt update && apt upgrade
  2. Run the CIS Ansible role — with --check first, then for real
  3. Disable unnecessary services — remove what you don’t need
  4. Harden filesystem mountsnoexec, nosuid, nodev on temp directories
  5. Apply sysctl hardening — network, kernel, and filesystem parameters
  6. Enforce AppArmor profiles — switch from complain to enforce
  7. Configure user account policies — PAM, password quality, account lockout
  8. Enable unattended-upgrades — security-only, with controlled reboot strategy
  9. Audit with OpenSCAP and Lynis — verify everything, schedule recurring scans

Conclusion

A default Ubuntu installation is optimized for ease of use, not security. CIS Benchmarks provide the prescriptive, auditable checklist to close that gap — and Ansible makes it repeatable across your infrastructure.

Hardening is not a one-time task. New CVEs appear daily (over 130 per day in 2025 according to OWASP), kernel vulnerabilities require sysctl updates, and configuration drift happens the moment humans touch a system. Automate the hardening, automate the patching, and automate the auditing. If you can’t prove your system is hardened, it isn’t.


References: