CIS Hardening of a Debian Linux Server Part 1: Level 1 Initial Setup

This is the second entry in the series documenting the Debian 13 server CIS hardening project.

This entry covers the benchmark’s Initial Setup section (1.1 through 1.7). Only Level 1 recommendations are addressed in this pass. Level 2 recommendations encountered along the way are noted and deferred to a later pass.

1.1: Filesystem #

1.1.1: Filesystem Kernel Modules #

This section covers 1.1.1.1 through 1.1.1.11 of the benchmark. These recommendations disable filesystem kernel modules that serve no purpose on a modern server: either legacy and niche filesystem formats, or removable storage buses that expand attack surface without operational benefit.

1.1.1.1 to 1.1.1.5: cramfs, freevxfs, hfs, hfsplus, jffs2 #

These five recommendations share an identical check script. Only the module name changes between runs.

#!/usr/bin/env bash
{
  l_mod_name="cramfs" l_mod_type="fs"
  while IFS= read -r l_mod_path; do
    if [ -d "$l_mod_path/${l_mod_name//-/\/}" ] && \
    [ -n "$(ls -A "$l_mod_path/${l_mod_name//-/\/}")" ]; then
      printf '%s\n' "$l_mod_name exists in $l_mod_path"
    fi
  done < <(readlink -e /usr/lib/modules/**/kernel/$l_mod_type \
  || readlink -e /lib/modules/**/kernel/$l_mod_type)
}

Substituting l_mod_name for freevxfs, hfs, hfsplus, and jffs2 in turn, every run returned nothing. None of these five filesystems ship as available modules on this Debian 13 kernel. No remediation was required for any of them.

1.1.1.6 to 1.1.1.8: overlay, squashfs, udf (Level 2, deferred) #

These three recommendations are Level 2 and are deferred. overlay is required by container runtimes, squashfs by snap, and udf is required by Microsoft Azure. All three will be revisited during the Level 2 pass.

1.1.1.9 to 1.1.1.10: firewire-core, usb-storage #

Same check pattern as above, pointed at drivers instead of fs as the module type. Neither firewire-core nor usb-storage returned as present on this system. No remediation required.

1.1.1.11: Unused Filesystems #

Baseline of what is currently mounted:

# findmnt -Dkerno fstype | sort -u
devtmpfs
ext4
tmpfs
vfat

The benchmark’s audit script enumerates every filesystem module physically present under the kernel module tree:

#!/usr/bin/env bash
{
  l_search="$(readlink -e /usr/lib/modules/ || readlink -e /lib/modules/)"
  a_module=()
  while IFS= read -r -d $'\0' l_module_dir; do
    if [ ! "$(basename "$l_module_dir")" = "nls" ]; then
      while IFS= read -r -d $'\0' l_module_file; do
      l_mname="$(basename "$l_module_file" | cut -d'.' -f1)"
        if [ -f "$l_module_file" ] && ! grep -Psiq -- '\b'"$l_mname"'\b' \
        <<< "${a_module[*]}"; then
          a_module+=("$l_mname")
        fi
      done < <(find -L "$l_module_dir" -mindepth 1 -maxdepth 1 -type f -print0)
    fi
  done < <(find "$l_search"/**/kernel/fs/ -mindepth 1 -maxdepth 1 -type d ! -empty -print0)
  printf '%s\n' "" "${a_module[@]}"
}

Cross-referencing that list against lsmod:

# ./generate-fs-km-list > ~/outputs/fs-KMs/generated-fs-km-list.txt
# lsmod | awk 'NR>1 {print $1}' > ~/outputs/fs-KMs/lsmod-list-1st-column.txt
# comm -12 <(sort lsmod-list-1st-column.txt) <(sort generated-fs-km-list.txt)
autofs4
btrfs
configfs
efivarfs
vfat

None of these five are self-evidently necessary merely by virtue of being loaded. Each is examined individually below.

Default blacklist entries already present on this image:

# modprobe --showconfig | grep -Pi -- '^\h*(blacklist|install)\h+'
blacklist arkfb
blacklist aty128fb
blacklist atyfb
blacklist radeonfb
blacklist cirrusfb
blacklist cyber2000fb
blacklist kyrofb
blacklist matroxfb_base
blacklist mb862xxfb
blacklist neofb
blacklist pm2fb
blacklist pm3fb
blacklist s3fb
blacklist savagefb
blacklist sisfb
blacklist tdfxfb
blacklist tridentfb
blacklist vt8623fb

All framebuffer drivers. Not relevant to this recommendation.

The full audit script sorts every discovered filesystem module into three categories: currently mounted, currently loaded, and merely loadable.

#!/usr/bin/env bash
{
  a_check=() a_output=() a_module=() a_output2=() a_output3=()
  l_search="$(readlink -e /usr/lib/modules/ || readlink -e /lib/modules/)"
  IFS=$'\n' read -r -d '' -a a_mounted < <(findmnt -Dkerno fstype \
  | sort -u && printf '\0' )
  IFS=$'\n' read -r -d '' -a a_lsmod < <(lsmod | awk '{print $1}' && printf '\0' )
  IFS=$'\n' read -r -d '' -a a_showconfig < <(modprobe --showconfig | \
  grep -Pi -- '^\h*(blacklist|install)\h+' && printf '\0')
  while IFS= read -r -d $'\0' l_module_dir; do
    if [ ! "$(basename "$l_module_dir")" = "nls" ]; then
      while IFS= read -r -d $'\0' l_module_file; do
        l_mname="$(basename "$l_module_file" | cut -d'.' -f1)"
        if [ -f "$l_module_file" ] && ! grep -Psiq -- '\b'"$l_mname"'\b' <<< "${a_module[*]}"; then
          a_module+=("$l_mname")
        fi
      done < <(find -L "$l_module_dir" -mindepth 1 -maxdepth 1 -type f -print0)
    fi
  done < <(find "$l_search"/**/kernel/fs/ -mindepth 1 -maxdepth 1 -type d ! -empty -print0)
  for l_module in "${a_module[@]}"; do
    if grep -Psoiq -- '\b'"$l_module"'\b' <<< "${a_mounted[*]}"; then
      a_output+=(" - \"$l_module\"")
    elif grep -Psoiq -- '\b'"$l_module"'\b' <<< "${a_lsmod[*]}"; then
      a_output2+=(" - \"$l_module\"")
    elif ! grep -Psioq -- '\binstall\h+'"${l_module//-/_}"'\h+\H+\b' <<< "${a_showconfig[*]}" || \
    ! grep -Psioq -- '\bblacklist\h+'"${l_module//-/_}"'\b' <<< "${a_showconfig[*]}"; then
      a_output3+=(" - \"$l_module\"")
    fi
  done
  [ "${#a_output[@]}" -gt 0 ] && printf '%s\n' "" \
  "- There are \"${#a_output[@]}\" kernel modules currently mounted:" "${a_output[@]}"
  [ "${#a_output2[@]}" -gt 0 ] && printf '%s\n' "" \
  "- There are \"${#a_output2[@]}\" kernel modules currently loaded:" "${a_output2[@]}"
  [ "${#a_output3[@]}" -gt 0 ] && printf '%s\n' "" \
  "- There are \"${#a_output3[@]}\" Kernel modules currently loadable:" "${a_output3[@]}"
}

Result:

- There are "1" kernel modules currently mounted:
 - "vfat"

- There are "5" kernel modules currently loaded:
 - "efivarfs"
 - "autofs4"
 - "fat"
 - "configfs"
 - "btrfs"

- There are "32" Kernel modules currently loadable:
 - "isofs"
 - "cuse"
 - "pstore_blk"
 - "ramoops"
 - "pstore_zone"
 - "squashfs"
 - "udf"
 - "msdos"
 - "bcachefs"
 - "erofs"
 - "quota_tree"
 - "quota_v2"
 - "quota_v1"
 - "zonefs"
 - "grace"
 - "nfs_acl"
 - "netfs"
 - "xfs"
 - "cachefiles"
 - "nfsd"
 - "nfsv3"
 - "nfsv4"
 - "nfs"
 - "nfsv2"
 - "romfs"
 - "ceph"
 - "orangefs"
 - "lockd"
 - "sysv"
 - "exfat"
 - "overlay"
 - "ntfs3"

This defines the scope of work. The loadable bucket is addressed first, since it covers what is not currently in use, before moving to what is already loaded or mounted.

lsblk reports an EFI partition at sda15, while /sys/firmware/efi does not exist on this system. This is the first indication that the host is BIOS-booted with a residual EFI partition rather than EFI-booted.

The loadable bucket #
  • isofs: mounts .iso files, not needed here.
  • cuse: extends the FUSE infrastructure and provides userspace character-device support, no operational use on this host.
  • pstore_blk, ramoops, pstore_zone: part of the kernel’s persistent-storage panic/diagnostic logging stack, currently not necessary.
  • msdos: legacy filesystem driver.
  • bcachefs: modern filesystem with no use case here.
  • erofs: Huawei’s lightweight read-only filesystem, not needed here.
  • quota_tree, quota_v1, quota_v2: /etc/fstab shows no existing quota configuration, and no active quota usage was identified on this host.
  • xfs: unused on this host.
  • netfs: kernel infrastructure support for other network filesystem drivers that are themselves unused.
  • nfs, nfsd, nfsv2, nfsv3, nfsv4, nfs_acl, lockd, grace: the NFS client/server stack, unused in its entirety.
  • cachefiles: on-disk backend for netfs, equally irrelevant.
  • exfat: No exFAT filesystem or required removable-storage workload is present on this host.
  • ntfs3: NTFS filesystem support, no dependent workload on this host.
  • zonefs, romfs, ceph, orangefs, sysv: niche, legacy, or distributed filesystems, none applicable here.

Remediation was applied using a modified version of the benchmark’s script, adjusted to accept the module name as an argument rather than a hardcoded value:

#!/usr/bin/env bash
{
  [[ -z "$1" ]] && printf "Please provide the module name\n"
  module_name=$1
  a_output2=(); a_output3=(); l_dl="" # Initialize arrays and clear variables
  l_mod_name="$module_name" # set module name
  l_mod_type="fs" # set module type
  l_mod_path="$(readlink -f /usr/lib/modules/**/kernel/$l_mod_type || readlink -f /lib/modules/**/kernel/$l_mod_type)"
  f_module_fix()
  {
    l_dl="y" # Set to ignore duplicate checks
    a_showconfig=() # Create array with modprobe output
    while IFS= read -r l_showconfig; do
      a_showconfig+=("$l_showconfig")
    done < <(modprobe --showconfig | grep -P -- '\b(install|blacklist)\h+'"${l_mod_name//-/_}"'\b')
    if lsmod | grep "$l_mod_name" &> /dev/null; then # Check if the module is currently loaded
      a_output2+=(" - unloading kernel module: \"$l_mod_name\"")
      modprobe -r "$l_mod_name" 2>/dev/null; rmmod "$l_mod_name" 2>/dev/null
    fi
    if ! grep -Pq -- '\binstall\h+'"${l_mod_name//-/_}"'\h+(\/usr)?\/bin\/(true|false)\b' <<< "${a_showconfig[*]}"; then
      a_output2+=(" - setting kernel module: \"$l_mod_name\" to \"$(readlink -f /bin/false)\"")
      printf '%s\n' "install $l_mod_name $(readlink -f /bin/false)" >> /etc/modprobe.d/"$l_mod_name".conf
    fi
    if ! grep -Pq -- '\bblacklist\h+'"${l_mod_name//-/_}"'\b' <<< "${a_showconfig[*]}"; then
      a_output2+=(" - denylisting kernel module: \"$l_mod_name\"")
      printf '%s\n' "blacklist $l_mod_name" >> /etc/modprobe.d/"$l_mod_name".conf
    fi
  }
  for l_mod_base_directory in $l_mod_path; do # Check if the module exists on the system
    if [ -d "$l_mod_base_directory/${l_mod_name/-/\/}" ] && [ -n "$(ls -A "$l_mod_base_directory/${l_mod_name/-/\/}")" ]; then
      a_output3+=(" - \"$l_mod_base_directory\"")
      [[ "$l_mod_name" =~ overlay ]] && l_mod_name="${l_mod_name::-2}"
      [ "$l_dl" != "y" ] && f_module_fix
    else
      echo -e " - kernel module: \"$l_mod_name\" doesn't exist in \"$l_mod_base_directory\""
    fi
  done
  [ "${#a_output3[@]}" -gt 0 ] && printf '%s\n' "" " -- INFO --" " - module: \"$l_mod_name\" exists in:" "${a_output3[@]}"
  [ "${#a_output2[@]}" -gt 0 ] && printf '%s\n' "" "${a_output2[@]}" || printf '%s\n' "" " - No changes needed"
  printf '%s\n' "" " - remediation of kernel module: \"$l_mod_name\" complete" ""
}

Applied against isofs, producing the expected configuration:

# cat /etc/modprobe.d/isofs.conf
install isofs /usr/bin/false
blacklist isofs

cuse does not fit the script’s path assumptions. Its .ko file resides under /lib/modules/6.12.86+deb13-cloud-amd64/kernel/fs/fuse/, one directory level deeper than .../kernel/fs/, which the script does not account for:

 - kernel module: "cuse" doesn't exist in "/usr/lib/modules/6.12.86+deb13-cloud-amd64/kernel/fs"
 - kernel module: "cuse" doesn't exist in "/usr/lib/modules/6.12.94+deb13-cloud-amd64/kernel/fs"

 - No changes needed

 - remediation of kernel module: "cuse" complete

modinfo cuse confirms the module exists regardless. The same path mismatch affects pstore_blk, ramoops, pstore_zone, msdos, the quota_* family, nfs_acl, nfsv2, nfsv3, nfsv4, and grace, all of which live one directory deeper than the script searches. Remediation for these was applied manually, for example:

echo "install cuse /bin/false" | sudo tee /etc/modprobe.d/cuse.conf
echo "blacklist cuse" | sudo tee -a /etc/modprobe.d/cuse.conf

Existence should be confirmed with modinfo directly rather than relying on the script’s silence.

A minimal generic script was used to process the remainder:

#!/usr/bin/env bash
l_mod_name="$1"
echo "install $l_mod_name /bin/false" | sudo tee /etc/modprobe.d/"$l_mod_name".conf
echo "blacklist $l_mod_name" | sudo tee -a /etc/modprobe.d/"$l_mod_name".conf

All modules in the loadable bucket were disabled by the end of this pass, with the exception of overlay, udf, and squashfs, which remain untouched pending the Level 2 pass.

The loaded bucket #

Five modules remained: efivarfs, autofs4, fat, configfs, btrfs.

Given the BIOS/EFI discrepancy noted above, and confirming the system boots via BIOS rather than EFI, efivarfs serves no function here and was therefore disabled. Similarly, lsblk -f confirms btrfs backs no mounted filesystem on this host, prompting its disablement. vfat (and by extension fat) backs /boot/efi and is left untouched pending further investigation into its role given the BIOS/EFI discrepancy.

autofs4 and configfs presented as straightforward removals and were not.

systemctl status autofs reports the unit does not exist, and /etc/auto.master is empty, both signs pointing to autofs4 being unused. Attempting removal fails:

# modprobe -r autofs4
modprobe: FATAL: Module autofs4 is in use.

The module is held open by systemd itself, via an automount unit unrelated to the classic autofs service:

# systemctl list-units --type=automount
  UNIT                              LOAD   ACTIVE SUB     DESCRIPTION
  proc-sys-fs-binfmt_misc.automount loaded active running Arbitrary Executable File Formats File System Automount Point

autofs4 is left in place.

configfs follows the same pattern. ls /sys/kernel/config returns empty, suggesting safe removal. Running the benchmark’s remediation script against it reports an unload with no error. The audit script, however, still lists configfs as loaded, and a manual unload attempt reproduces the identical FATAL error seen with autofs4. configfs is in fact mounted, with systemd mounting it by default as part of system initialization, and the audit script under-reports it due to a quirk in the used findmnt command, which is why it doesn’t appear in the “mounted” bucket. mount | grep configfs confirms this.

Since /sys/kernel/config is confirmed empty and unused, configfs was unmounted directly, and the corresponding unit masked to prevent systemd from remounting it on subsequent boots:

# umount /sys/kernel/config
# systemctl mask sys-kernel-config.mount
Created symlink '/etc/systemd/system/sys-kernel-config.mount' → '/dev/null'.

With the mount cleared, the module was unloaded and disabled using the same procedure applied elsewhere. Persistence across a reboot was confirmed.

Final audit state following all remediation:

# ./deny-and-not-loadable-audit 

- There are "1" kernel modules currently mounted:
 - "vfat"

- There are "2" kernel modules currently loaded:
 - "autofs4"
 - "fat"

- There are "3" Kernel modules currently loadable:
 - "squashfs"
 - "udf"
 - "overlay"

Every module remaining is either operationally required (vfat/fat for /boot/efi and autofs4 for systemd’s binfmt_misc automount) or deliberately deferred to the Level 2 pass (squashfs, udf, overlay).

1.1.2: Configure Filesystem Partitions #

This section covers 1.1.2.1 (/tmp) and 1.1.2.2 (/dev/shm) of the benchmark. Placing directories used for system-wide functions on their own partitions guards against resource exhaustion and allows mount options tailored to each directory’s intended use. The remaining recommendations, 1.1.2.3 through 1.1.2.7 (/home, /var, /var/tmp, /var/log, /var/log/audit), are Level 2 and will be addressed in that pass.

1.1.2.1: Configure /tmp #

1.1.2.1.1: Ensure /tmp Is tmpfs or a Separate Partition #
# findmnt -kn /tmp
/tmp tmpfs tmpfs rw,nosuid,nodev,size=1959128k,nr_inodes=1048576,inode64

/tmp is tmpfs, which satisfies this recommendation.

# systemctl is-enabled tmp.mount
static

The unit is neither masked nor disabled, so systemd mounts it at boot.

1.1.2.1.2: Ensure nodev Option Set on /tmp Partition #

The benchmark’s wording here is ambiguous, phrased as applying “if a separate partition exists for /tmp,” which is read as covering the tmpfs case as well.

# findmnt -kn /tmp | grep -v nodev

No output. nodev is already set.

1.1.2.1.3: Ensure nosuid Option Set on /tmp Partition #
# findmnt -kn /tmp | grep -v nosuid

No output. nosuid is already set.

1.1.2.1.4: Ensure noexec Option Set on /tmp Partition #
# findmnt -kn /tmp | grep -v noexec
/tmp tmpfs tmpfs rw,nosuid,nodev,size=1959128k,nr_inodes=1048576,inode64

noexec is not set. The fix is applied directly in /etc/fstab:

tmpfs   /tmp   tmpfs   rw,nosuid,nodev,noexec,size=1959128k,nr_inodes=1048576,inode64   0   0
# sudo mount -o remount /tmp
mount: (hint) your fstab has been modified, but systemd still uses
       the old version; use 'systemctl daemon-reload' to reload.

Following the hint:

# systemctl daemon-reload
# sudo mount -o remount /tmp
# findmnt -kn /tmp | grep -v noexec

No output. noexec is now set.

1.1.2.2: Configure /dev/shm #

1.1.2.2.1: Ensure /dev/shm Is tmpfs or a Separate Partition #
# findmnt -kn /dev/shm
/dev/shm tmpfs tmpfs rw,nosuid,nodev,inode64

/dev/shm is tmpfs, which satisfies this recommendation.

1.1.2.2.2: Ensure nodev Option Set on /dev/shm Partition #
# findmnt -kn /dev/shm | grep -v nodev

No output. nodev is already set.

1.1.2.2.3: Ensure nosuid Option Set on /dev/shm Partition #
# findmnt -kn /dev/shm | grep -v nosuid

No output. nosuid is already set.

1.1.2.2.4: Ensure noexec Option Set on /dev/shm Partition #
# findmnt -kn /dev/shm | grep -v 'noexec'
/dev/shm tmpfs tmpfs rw,nosuid,nodev,inode64

noexec is not set. Added to /etc/fstab:

tmpfs   /dev/shm   tmpfs   rw,nosuid,nodev,noexec,inode64   0   0
# systemctl daemon-reload
# sudo mount -o remount /dev/shm
# findmnt -kn /dev/shm | grep -v 'noexec'

No output. noexec is now set.

1.2: Package Management #

This section covers 1.2.1 and 1.2.2 of the benchmark. The policy adopted here is manual updates during active hardening, avoiding unattended changes mid-investigation, with a transition to unattended-upgrades on a security-only scope once the hardening is complete.

1.2.1: Configure Package Repositories #

1.2.1.1: Ensure the source.list and .source Files Use the Signed-By Option #

# grep -PRLs -- '^([^#\n\r]+)?\bSigned-By\b' /etc/apt/sources.list /etc/apt/sources.list.d/*.{list,sources}
/etc/apt/sources.list

/etc/apt/sources.list is flagged, but only because the file is empty. It isn’t a compliance issue.

# ls /etc/apt/sources.list.d
debian.sources  hetzner-mirror.sources  hetzner-security-mirror.sources

All three files are compliant.

A decision was made to prefer the Debian repositories over the Hetzner mirrors, to minimize attack surface. Both Hetzner .sources files were set to Enabled: no.

1.2.1.2: Ensure Weak Dependencies Are Configured #

Level 2, deferred. The recommendation appears to pass the audit regardless.

1.2.1.3: Ensure Access to GPG Key Files Are Configured #

Audit script verifying that all .gpg key files under /usr/share/keyrings/ and /etc/apt/trusted.gpg.d/ are mode 0644 or more restrictive, owned by root, and group-owned by root:

#! /usr/bin/env bash
{
  while IFS= read -r -d $'\0' l_file; do
    stat -Lc 'File: %n Mode: (%#a) User: (%U) Group: (%G)' "$l_file"
  done < <(find -L /usr/share/keyrings/ /etc/apt/trusted.gpg.d/ \
  -mount -xdev -type f \( ! -user root -o ! -group root -o -perm /133 \) \
  -name '*gpg' -print0)
}

Nothing returned.

A second script verifies the same for .list or .sources files that include the Signed-By option:

#!/usr/bin/env bash
{
  while IFS= read -r -d $'\0' l_file; do
    grep -Psq -- '^([^#\n\r]+)?\bSigned-By\b' "$l_file" && \
    stat -Lc 'File: %n Mode: (%#a) User: (%U) Group: (%G)' "$l_file"
  done < <(find -L /etc/apt/sources.list.d/ -mount -xdev -type f \
  \( ! -user root -o ! -group root -o -perm /133 \) \
  \( -name '*.list' -o -name '*.sources' \) -print0)
}

Nothing returned.

The recommendation passes.

1.2.1.4: Ensure Access to /etc/apt/trusted.gpg.d Directory Is Configured #

# stat -Lc 'Access: (%a/%A) Uid: ( %u/ %U) Gid: ( %g/ %G)' /etc/apt/trusted.gpg.d
Access: (755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)

Compliant.

1.2.1.5: Ensure Access to /etc/apt/auth.conf.d Directory Is Configured #

# stat -Lc 'Access: (%a/%A) Uid: ( %u/ %U) Gid: ( %g/ %G)' /etc/apt/auth.conf.d
Access: (755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)

Compliant.

1.2.1.6: Ensure Access to Files in the /etc/apt/auth.conf.d/ Directory Is Configured #

# stat -Lc 'Access: (%a/%A) Uid: ( %u/ %U) Gid: ( %g/ %G)' /etc/apt/auth.conf.d/* 2>/dev/null

No output, since no files exist under /etc/apt/auth.conf.d, consistent with there being no repositories that require authentication.

1.2.1.7: Ensure Access to /usr/share/keyrings Directory Is Configured #

# stat -Lc 'Access: (%a/%A) Uid: ( %u/ %U) Gid: ( %g/ %G)' /usr/share/keyrings
Access: (755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)

Compliant.

1.2.1.8: Ensure Access to /etc/apt/sources.list.d Directory Is Configured #

# stat -Lc 'Access: (%a/%A) Uid: ( %u/ %U) Gid: ( %g/ %G)' /etc/apt/sources.list.d
Access: (755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)

Compliant.

1.2.1.9: Ensure Access to Files in /etc/apt/sources.list.d Are Configured #

# stat -Lc 'Access: (%a/%A) Uid: ( %u/ %U) Gid: ( %g/ %G)' /etc/apt/sources.list.d/*
Access: (644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: (644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: (644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)

Compliant.

1.2.2: Configure Package Updates #

1.2.2.1: Ensure Updates, Patches, and Additional Security Software Are Installed #

# apt update
Hit:1 http://deb.debian.org/debian trixie InRelease
Hit:2 http://deb.debian.org/debian trixie-updates InRelease
Hit:3 http://deb.debian.org/debian trixie-backports InRelease
Get:4 http://deb.debian.org/debian-security trixie-security InRelease [43.4 kB]
Fetched 43.4 kB in 0s (272 kB/s)
All packages are up to date.
# apt -s upgrade

Summary:                        
  Upgrading: 0, Installing: 0, Removing: 0, Not Upgrading: 0
# if [ -f /var/run/reboot-required ]; then
cat /var/run/reboot-required
fi

No output from the last command. /var/run/reboot-required does not exist, confirming no reboot is required.

Manual updates continue for now. Automatic updates will be enabled once the rest of the hardening is complete.

1.3: Mandatory Access Control #

1.3.1: Configure AppArmor #

1.3.1.1: Ensure apparmor packages are installed #

# dpkg-query -s apparmor &>/dev/null && echo "apparmor is installed"
# dpkg-query -s apparmor-utils &>/dev/null && echo "apparmor-utils is installed"

Neither apparmor nor apparmor-utils is installed. Installed with apt install apparmor apparmor-utils.

1.3.1.2: Ensure AppArmor is enabled #

# grep "^\s*linux" /boot/grub/grub.cfg | grep "apparmor=0"

No output. Compliant.

1.3.1.3: Ensure all AppArmor Profiles are enforcing #

Level 2, deferred. For reference, the current state:

# apparmor_status | grep profiles
105 profiles are loaded.
6 profiles are in enforce mode.
23 profiles are in complain mode.
0 profiles are in prompt mode.
0 profiles are in kill mode.
76 profiles are in unconfined mode.
0 processes have profiles defined.
# apparmor_status | grep processes
0 processes have profiles defined.
0 processes are in enforce mode.
0 processes are in complain mode.
0 processes are in prompt mode.
0 processes are in kill mode.
0 processes are unconfined but have a profile defined.
0 processes are in mixed mode.

Not all profiles are enforcing, and no process currently has a profile defined.

1.3.1.4: Ensure apparmor_restrict_unprivileged_unconfined is enabled #

# sysctl kernel.apparmor_restrict_unprivileged_unconfined
kernel.apparmor_restrict_unprivileged_unconfined = 0

Not compliant.

Script checking for an existing configuration file that sets this value:

#!/usr/bin/env bash
{
  l_parameter_name="kernel.apparmor_restrict_unprivileged_unconfined"
  l_grep="${l_parameter_name//./\\.}" a_output=()
  l_systemdsysctl="$(readlink -e /lib/systemd/systemd-sysctl || readlink -e /usr/lib/systemd/systemd-sysctl)"
  l_ufwscf="$([ -f /etc/default/ufw ] && awk -F= '/^\s*IPT_SYSCTL=/ {print $2}' /etc/default/ufw)"
  l_opt="$(grep -Psoi '^\h*'"$l_grep"'\h*=\h*\H+\b' "$l_ufwscf" | tail -n 1)"
  l_option_value="$(cut -d= -f2 <<< "$l_opt" | xargs)"
  [ -n "$l_option_value" ] && a_output+=(" - UFW set: \"$l_parameter_name\" to: \"$l_option_value\" in: \"$l_file\"")
  while IFS= read -r l_file; do
    l_file="${l_file//# /}"
    l_opt="$(grep -Psoi '^\h*'"$l_grep"'\h*=\h*\H+\b' "$l_file" | tail -n 1)"
    l_option_value="$(cut -d= -f2 <<< "$l_opt" | xargs)"
    [ -n "$l_option_value" ] && a_output+=(" - \"$l_parameter_name\" is set to: \"$l_option_value\" in: \"$l_file\"")
  done < <("$l_systemdsysctl" --cat-config | tac | grep -Psoi '^\h*#\h*\/[^#\n\r\h]+\.conf\b')
  [ "${#a_output[@]}" -gt "0" ] && printf '%s\n' "" "${a_output[@]}" ""
}

No output. Not compliant.

Remediation:

# printf "\n%s\n" "kernel.apparmor_restrict_unprivileged_unconfined = 1" >> /etc/sysctl.d/60-kernel_sysctl.conf
# sysctl -w kernel.apparmor_restrict_unprivileged_unconfined=1
kernel.apparmor_restrict_unprivileged_unconfined = 1
# sysctl --system
* Applying /usr/lib/sysctl.d/10-coredump-debian.conf ...
* Applying /usr/lib/sysctl.d/50-default.conf ...
* Applying /usr/lib/sysctl.d/50-pid-max.conf ...
* Applying /etc/sysctl.d/60-kernel_sysctl.conf ...
kernel.core_pattern = core
kernel.sysrq = 0x01b6
kernel.core_uses_pid = 1
net.ipv4.conf.default.rp_filter = 2
net.ipv4.conf.eth0.rp_filter = 2
net.ipv4.conf.lo.rp_filter = 2
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.eth0.accept_source_route = 0
net.ipv4.conf.lo.accept_source_route = 0
net.ipv4.conf.default.promote_secondaries = 1
net.ipv4.conf.eth0.promote_secondaries = 1
net.ipv4.conf.lo.promote_secondaries = 1
net.ipv4.ping_group_range = 0 2147483647
net.core.default_qdisc = fq_codel
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_regular = 2
fs.protected_fifos = 1
vm.max_map_count = 1048576
kernel.pid_max = 4194304
kernel.apparmor_restrict_unprivileged_unconfined = 1
# sysctl kernel.apparmor_restrict_unprivileged_unconfined
kernel.apparmor_restrict_unprivileged_unconfined = 1

Compliant.

1.4: Configure Bootloader #

1.4.1: Ensure bootloader password is set #

# grep "^set superusers" /boot/grub/grub.cfg
# awk -F. '/^\s*password/ {print $1"."$2"."$3}' /boot/grub/grub.cfg

No output. Not compliant.

A password hash is generated:

# grub-mkpasswd-pbkdf2 --iteration-count=600000 --salt=64
Enter password: 
Reenter password: 
PBKDF2 hash of your password is grub.pbkdf2.sha512.600000.<REDACTED>

This is appended to /etc/grub.d/40_custom (or any custom file in that directory):

set superusers="root"
password_pbkdf2 root grub.pbkdf2.sha512.600000.<REDACTED>

root here is just the chosen username for the GRUB superuser entry. It does not need to correspond to an actual system user, though root is the conventional choice.

--unrestricted is added to /etc/grub.d/10_linux, on the CLASS= line, so that the password isn’t required for a plain boot or reboot, to avoid availability problems.

# update-grub
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-6.12.94+deb13-cloud-amd64
Found initrd image: /boot/initrd.img-6.12.94+deb13-cloud-amd64
Found linux image: /boot/vmlinuz-6.12.86+deb13-cloud-amd64
Found initrd image: /boot/initrd.img-6.12.86+deb13-cloud-amd64
Warning: os-prober will not be executed to detect other bootable partitions.
Systems on them will not be added to the GRUB boot configuration.
Check GRUB_DISABLE_OS_PROBER documentation entry.
Adding boot menu entry for UEFI Firmware Settings ...
done

1.4.2: Ensure access to bootloader config is configured #

Even read access should be restricted to root, as broader read access could help an attacker locate weaknesses.

# stat -Lc 'Access: (%#a/%A) Uid: ( %u/ %U) Gid: ( %g/ %G)' /boot/grub/grub.cfg
Access: (0600/-rw-------) Uid: ( 0/ root) Gid: ( 0/ root)

Compliant. Nothing was changed here. Per the benchmark, the default should have been 0644. This may be a Hetzner-specific addition to the image.

1.5: Configure Additional Process Hardening #

# sysctl fs.protected_hardlinks
fs.protected_hardlinks = 1

Compliant.

#!/usr/bin/env bash
{
  l_parameter_name="fs.protected_hardlinks"
  l_grep="${l_parameter_name//./(\\.|\\/)}" a_output=() a_files=()
  l_systemdsysctl="$(readlink -e /lib/systemd/systemd-sysctl \
  || readlink -e /usr/lib/systemd/systemd-sysctl)"
  l_ufw_file="$([ -f /etc/default/ufw ] && \
  awk -F= '/^\s*IPT_SYSCTL=/ {print $2}' /etc/default/ufw)"
  [ -f "$(readlink -e "$l_ufw_file")" ] && \
  a_files+=("$l_ufw_file"); a_files+=("/etc/sysctl.conf")
  while IFS= read -r l_fname; do
    l_file="$(readlink -e "${l_fname//# /}")"
    [ -n "$l_file" ] && ! grep -Psiq -- '(^|\h+)'"$l_file"'\b' \
    <<< "${a_files[*]}" && a_files+=("$l_file")
  done < <("$l_systemdsysctl" --cat-config | tac | \
  grep -Psio -- '^\h*#\h*\/[^#\n\r\h]+\.conf\b')
  for l_file in "${a_files[@]}"; do
    l_opt="$(grep -Psoi '^\h*'"$l_grep"'\h*=\h*\H+\b' "$l_file" | tail -n 1)"
    l_option_value="$(cut -d= -f2 <<< "$l_opt" | xargs)"
    [ -n "$l_option_value" ] && \
    a_output+=(" - \"$l_parameter_name = $l_option_value\" is set in:" \
    " \"$l_file\"")
  done
  [ "${#a_output[@]}" -gt "0" ] && printf '%s\n' "" "${a_output[@]}" ""
}

Output:

 - "fs.protected_hardlinks = 1" is set in:
 "/usr/lib/sysctl.d/50-default.conf

Compliant.

Level 2, deferred. Already set to 1 regardless.

1.5.3: Ensure kernel.yama.ptrace_scope is configured #

# sysctl kernel.yama.ptrace_scope
kernel.yama.ptrace_scope = 0

Not compliant. The value should be 1, 2, or 3.

#!/usr/bin/env bash
{
  l_parameter_name="kernel.yama.ptrace_scope"
  l_grep="${l_parameter_name//./\\.}" a_output=()
  l_systemdsysctl="$(readlink -e /lib/systemd/systemd-sysctl || readlink -e /usr/lib/systemd/systemd-sysctl)"
  l_ufwscf="$([ -f /etc/default/ufw ] && awk -F= '/^\s*IPT_SYSCTL=/ {print $2}' /etc/default/ufw)"
  l_opt="$(grep -Psoi '^\h*'"$l_grep"'\h*=\h*\H+\b' "$l_ufwscf" | tail -n 1)"
  l_option_value="$(cut -d= -f2 <<< "$l_opt" | xargs)"
  [ -n "$l_option_value" ] && a_output+=(" - UFW set: \"$l_parameter_name\" to: \"$l_option_value\" in: \"$l_file\"")
  while IFS= read -r l_file; do
    l_file="${l_file//# /}"
    l_opt="$(grep -Pois '^\h*'"$l_grep"'\h*=\h*\H+\b' "$l_file" | tail -n 1)"
    l_option_value="$(cut -d= -f2 <<< "$l_opt" | xargs)"
    [ -n "$l_option_value" ] && a_output+=(" - \"$l_parameter_name\" is set to: \"$l_option_value\" in: \"$l_file\"")
  done < <("$l_systemdsysctl" --cat-config | tac | grep -Pios '^\h*#\h*\/[^#\n\r\h]+\.conf\b')
  [ "${#a_output[@]}" -gt "0" ] && printf '%s\n' "" "${a_output[@]}" ""
}

No output. Not compliant.

Remediation script, commenting out any existing non-compliant setting:

#!/usr/bin/env bash
{
  l_option="kernel.yama.ptrace_scope" l_grep="${l_option//./\\.}"
  l_value="(1|2|3)"
  while IFS= read -r -d $'\0' l_file; do
    grep -Pois '\h*'"$l_option"'\h*=\h*\H+\b' "$l_file" \
    | grep -Pivq '^\h*'"$l_grep"'\h*=\h*'"$l_value"'\b' && \
    sed -ri '/^\s*kernel.yama.ptrace_scope\s*=/s/^/# /' "$l_file"
  done < <(find /etc/sysctl.d/ -type f -name '*.conf' -print0)
}

No output. A .conf file still needs to be added or edited in /etc/sysctl.d/. The custom file created earlier, /etc/sysctl.d/60-kernel_sysctl.conf, is edited to add kernel.yama.ptrace_scope = 1.

# sysctl --system
# sysctl kernel.yama.ptrace_scope
kernel.yama.ptrace_scope = 1

Compliant.

1.5.4: Ensure fs.suid_dumpable is configured #

# sysctl fs.suid_dumpable
fs.suid_dumpable = 0

Compliant.

The corresponding audit script returns nothing. The value isn’t set in any specific configuration file, only the kernel default applies. It is still added explicitly to the custom file /etc/sysctl.d/60-kernel_sysctl.conf, followed by sysctl --system. The script then reports the value as set in that file.

1.5.5: Ensure kernel.dmesg_restrict is configured #

# sysctl kernel.dmesg_restrict
kernel.dmesg_restrict = 1

Compliant. The audit script again returns nothing. The value is written explicitly to the custom file regardless, followed by sysctl --system.

# dpkg-query -s prelink &>/dev/null && echo "prelink is installed"

No output. Compliant.

1.5.7: Ensure Automatic Error Reporting is configured #

This recommendation concerns Canonical’s Apport error-reporting service.

# dpkg-query -s apport &> /dev/null && grep -Psi -- '^\h*enabled\h*=\h*[^0]\b' /etc/default/apport
# systemctl is-active apport.service | grep '^active'

Both return nothing. Compliant.

1.5.8: Ensure kernel.kptr_restrict is configured #

# sysctl kernel.kptr_restrict
kernel.kptr_restrict = 0

Not compliant. Kernel pointers are not being replaced with zeroes for users lacking CAP_SYSLOG. The audit script returns nothing. Remediation adds kernel.kptr_restrict = 1 to the custom file, followed by sysctl --system.

The benchmark states the default value for this parameter is 1. The value found on this host was 0.

1.5.9: Ensure kernel.randomize_va_space is configured #

# sysctl kernel.randomize_va_space
kernel.randomize_va_space = 2

Compliant. The audit script returns nothing. The value is still added explicitly to the custom file, kernel.randomize_va_space = 2.

1.5.10: Ensure kernel.yama.ptrace_scope is configured #

This recommendation duplicates 1.5.3 and was already addressed there.

1.5.11: Ensure core file size is configured #

# grep -Psi -- '^\h*\*\h+hard\h+core\b' /etc/security/limits.conf /etc/security/limits.d/*
/etc/security/limits.d/10-coredump-debian.conf:*               hard    core            infinity

Not compliant. The value should be 0, not infinity.

Remediation attempted using the benchmark’s example command, which comments out entries with a hard core value above 0:

# sed -ri '/^\s*[^#\n\r]+\s+hard\s+core\s+([1-9][0-9]*)/s/^/# /' /etc/security/limits.conf /etc/security/limits.d/*

No output, but the change did not take effect. The file in question:

# cat /etc/security/limits.d/10-coredump-debian.conf
*               soft    core            0
root            soft    core            0
*               hard    core            infinity
root            hard    core            infinity

This is the only file in limits.d, and /etc/security/limits.conf has nothing uncommented. Rather than edit this file directly, a new file is created in limits.d that sorts lexicographically after it, 60-cis-hardening.conf, matching the naming convention already in use.

The benchmark’s example remediation regex does not account for the value infinity, which is why it failed to match. Both hard-limit lines in the original file are commented out by hand. Only the * entry is actually in scope for this recommendation, the root line isn’t.

# grep -Psi -- '^\h*\*\h+hard\h+core\b' /etc/security/limits.conf /etc/security/limits.d/*
/etc/security/limits.d/60-cis-hardening.conf:*               hard    core            0

Compliant.

1.5.12: Ensure systemd-coredump ProcessSizeMax is configured #

#!/usr/bin/env bash
{
  l_analyze_cmd="$(readlink -e /bin/systemd-analyze || \
  readlink -e /usr/bin/systemd-analyze)"
  l_conf_file="systemd/coredump.conf" l_block="Coredump"
  l_option="ProcessSizeMax" l_option_value="" a_output=()
  while IFS= read -r l_file; do
    l_file="${l_file//# /}"
    l_opt="$(awk '/\['"$l_block"'\]/{a=1;next}/\[/{a=0}a' "$l_file" \
    2>/dev/null | grep -Poi '^\h*'"$l_option"'\h*=\h*\H+\b' | tail -n 1)"
    l_option_value="$(cut -d= -f2 <<< "$l_opt" | xargs)"
    [ -n "$l_option_value" ] && \
    a_output+=(" - \"$l_option\" is set to: \"$l_option_value\"" \
    " in: \"$l_file\"")
  done < <("$l_analyze_cmd" cat-config "$l_conf_file" | tac | \
  grep -Pio '^\h*#\h*\/[^#\n\r\h]+\.conf\b')
  if [ "${#a_output[@]}" -le "0" ]; then
    l_file="$(readlink -e /etc/"$l_conf_file" || \
    readlink -e /usr/lib/"$l_conf_file")"
    l_opt="$(awk '/\['"$l_block"'\]/{a=1;next}/\[/{a=0}a' "$l_file" \
    2>/dev/null | grep -Poim 1 '^(\h*#)?\h*'"$l_option"'\h*=\h*\H+\b')"
    l_option_value="$(cut -d= -f2 <<< "${l_opt//# /}" | xargs)"
    [ -n "$l_option_value" ] && \
    a_output+=(" - The default value: \"${l_opt//#/}\"" \
    " is being used in the configuration")
  fi
  [ "${#a_output[@]}" -gt "0" ] && printf '%s\n' "" "${a_output[@]}" ""
}

This hangs. One theory: awk hangs because neither /etc/systemd/coredump.conf nor /usr/lib/systemd/coredump.conf exists. Creating the file resolves this:

# touch /etc/systemd/coredump.conf

The script then returns no output. A file 60-cis-hardening.conf is created in /etc/systemd/coredump.conf.d/:

[Coredump]
ProcessSizeMax=0

Attempting to reload:

# systemctl reload-or-restart systemd-coredump.socket
Failed to reload-or-restart systemd-coredump.socket: Unit systemd-coredump.socket not found.

The benchmark notes that if the systemd-coredump package is not installed, that counts as a passing state. Confirming:

# dpkg -s systemd-coredump 2>/dev/null | grep -i status

No output.

# dpkg -s systemd-coredump
dpkg-query: package `systemd-coredump` is not installed and no information is available
Use dpkg --info (= dpkg-deb --info) to examine archive files.

systemd-coredump is not installed, so the recommendation passes. The configuration files created while troubleshooting are removed.

1.5.13: Ensure systemd-coredump Storage is configured #

systemd-coredump is not installed, so this recommendation passes as well.

1.6: Configure Command Line Warning Banners #

1.6.1: Ensure /etc/motd is configured #

#!/usr/bin/env bash
{
  l_output="" l_output2=""
  a_files=()
  for l_file in /etc/motd{,.d/*}; do
    if grep -Psqi -- "(\\\v|\\\r|\\\m|\\\s|\b$(grep ^ID= /etc/os-release |
      cut -d= -f2 | sed -e 's/"//g')\b)" "$l_file"; then
      l_output2="$l_output2\n - File: \"$l_file\" includes system information"
    else
      a_files+=("$l_file")
    fi
  done
  if [ "${#a_files[@]}" -gt 0 ]; then
    echo -e "\n- ** Please review the following files and verify their contents follow local site policy **\n"
    printf '%s\n' "${a_files[@]}"
  elif [ -z "$l_output2" ]; then
    echo -e "- ** No MOTD files with any size were found. Please verify this conforms to local site policy ** -"
  fi
  if [ -z "$l_output2" ]; then
    l_output=" - No MOTD files include system information"
    echo -e "\n- Audit Result:\n ** PASS **\n$l_output\n"
  else
    echo -e "\n- Audit Result:\n ** FAIL **\n - Reason(s) for audit failure:\n$l_output2\n"
  fi
}

Output of the audit script:

- ** Please review the following files and verify their contents follow local site policy **

/etc/motd.d/*

- Audit Result:
 ** FAIL **
 - Reason(s) for audit failure:

 - File: "/etc/motd" includes system information

Contents of the file:

# cat /etc/motd

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.

The directory /etc/motd.d does not exist. The contents of /etc/motd are replaced with:

Authorized access only.

New audit result:

- ** Please review the following files and verify their contents follow local site policy **

/etc/motd
/etc/motd.d/*

- Audit Result:
 ** PASS **
 - No MOTD files include system information

1.6.2: Ensure /etc/issue is configured #

# cat /etc/issue
Debian GNU/Linux 13 \n \l

Not compliant. Contents replaced with:

Authorized users only. All activity may be monitored and reported.

1.6.3: Ensure /etc/issue.net is configured #

/etc/issue.net serves the same purpose as /etc/issue, but for remote connections.

# cat /etc/issue.net
Debian GNU/Linux 13

Not compliant. Fixed the same way:

# cat /etc/issue.net
Authorized users only. All activity may be monitored and reported.

1.6.4: Ensure access to /etc/motd is configured #

# [ -e /etc/motd ] && stat -Lc 'Access: (%#a/%A) Uid: ( %u/ %U) Gid: { %g/ %G)' /etc/motd
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: { 0/ root)

Compliant.

1.6.5: Ensure access to /etc/issue is configured #

# stat -Lc 'Access: (%#a/%A) Uid: ( %u/ %U) Gid: { %g/ %G)' /etc/issue
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: { 0/ root)

Compliant.

1.6.6: Ensure access to /etc/issue.net is configured #

# stat -Lc 'Access: (%#a/%A) Uid: ( %u/ %U) Gid: { %g/ %G)' /etc/issue.net
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: { 0/ root)

Compliant.

1.7: Configure GNOME Display Manager #

# dpkg-query -s gdm3 &>/dev/null && echo "gdm3 is installed"

No output. GDM is not installed on this system.

1.7.1: Ensure GDM is removed #

GDM’s absence directly satisfies this recommendation.

The remaining recommendations in this section configure GDM-specific behavior and don’t apply where GDM isn’t installed. They are skipped in their entirety.

With this, the Initial Setup section (1.1 through 1.7) is complete. Running Lynis again gives a score of 68, three points above the starting baseline.

================================================================================

  Lynis security scan details:

  Scan mode:
  Normal [▆]  Forensics [ ]  Integration [ ]  Pentest [ ]

  Lynis modules:
  - Compliance status      [?]
  - Security audit         [V]
  - Vulnerability scan     [V]

  Details:
  Hardening index : 68 [#############       ]
  Tests performed : 274
  Plugins enabled : 2

  Software components:
  - Firewall               [V]
  - Intrusion software     [X]
  - Malware scanner        [X]

  Files:
  - Test and debug information      : /var/log/lynis.log
  - Report data                     : /var/log/lynis-report.dat

================================================================================