CIS Hardening of a Debian Linux Server Part 7: Level 1 System Maintenance

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

This entry covers sections 7.1 and 7.2 of the benchmark. This is a Level 1 pass. Level 2 recommendations are deferred.

As the benchmark notes, the recommendations in this section function as ongoing maintenance rather than one-time fixes.

7.1 Configure System File and Directory Access #

This section covers 7.1.1 through 7.1.13 of the benchmark: ownership and permission checks on the core account and authentication files, followed by broader filesystem checks for world writable files, unowned files and directories, and SUID/SGID binaries.

7.1.1 to 7.1.9: Ownership and Permissions on Core Account Files #

These nine recommendations share the same audit pattern, checking ownership and permissions on a single file:

# stat -Lc 'Access: (%#a/%A) Uid: ( %u/ %U) Gid: ( %g/ %G)' <file>
RecommendationFileResult
7.1.1/etc/passwdAccess: (0644/-rw-r–r–) Uid: ( 0/ root) Gid: ( 0/ root)
7.1.2/etc/passwd-Access: (0644/-rw-r–r–) Uid: ( 0/ root) Gid: ( 0/ root)
7.1.3/etc/groupAccess: (0644/-rw-r–r–) Uid: ( 0/ root) Gid: ( 0/ root)
7.1.4/etc/group-Access: (0644/-rw-r–r–) Uid: ( 0/ root) Gid: ( 0/ root)
7.1.5/etc/shadowAccess: (0640/-rw-r—–) Uid: ( 0/ root) Gid: ( 42/ shadow)
7.1.6/etc/shadow-Access: (0640/-rw-r—–) Uid: ( 0/ root) Gid: ( 42/ shadow)
7.1.7/etc/gshadowAccess: (0640/-rw-r—–) Uid: ( 0/ root) Gid: ( 42/ shadow)
7.1.8/etc/gshadow-Access: (0640/-rw-r—–) Uid: ( 0/ root) Gid: ( 42/ shadow)
7.1.9/etc/shellsAccess: (0644/-rw-r–r–) Uid: ( 0/ root) Gid: ( 0/ root)

All nine pass without remediation.

7.1.10 Ensure Access to /etc/security/opasswd Is Configured #

This recommendation only applies if the file exists, so the audit checks for its presence before evaluating permissions.

# [ -e "/etc/security/opasswd" ] && stat -Lc '%n Access: (%#a/%A) Uid: (%u/ %U) Gid: ( %g/ %G)' /etc/security/opasswd
/etc/security/opasswd Access: (0600/-rw-------) Uid: (0/ root) Gid: ( 0/ root)
# [ -e "/etc/security/opasswd.old" ] && stat -Lc '%n Access: (%#a/%A) Uid: ( %u/ %U) Gid: ( %g/ %G)' /etc/security/opasswd.old

opasswd exists with correct permissions. opasswd.old does not exist, which is also a pass condition for this recommendation.

7.1.11 Ensure World Writable Files and Directories Are Secured #

#!/usr/bin/env bash
{
  a_output=() a_output2=() a_file=() a_dir=() l_smask='01000'
  l_exclude="nfs|proc|cifs|smb|vfat|iso9660|efivarfs|selinuxfs|ncpfs"
  l_exclude2="\/run|\/tmp|\/var\/tmp"
  a_path=(-path "*/containers/storage/*" -o -path "*/containerd/*" -o -path "*/kubelet/*" -o -path "/sys/*" -o -path "/snap/*" -o -path "/boot/efi/*")
  while IFS= read -r l_mount; do
    while IFS= read -r -d $'\0' l_file; do
      if [ -e "$l_file" ]; then
        [ -f "$l_file" ] && a_file+=("$l_file")
        if [ -d "$l_file" ]; then
          l_mode="$(stat -Lc '%#a' "$l_file")"
          [ ! $(( $l_mode & $l_smask )) -gt 0 ] && a_dir+=("$l_file")
        fi
      fi
    done < <(find "$l_mount" -mount -xdev \( "${a_path[@]}" \) \
    -prune -o \( -type f -o -type d \) -perm -0002 -print0 2> /dev/null)
  done < <(findmnt -Dkerno fstype,target | \
  awk '($1 !~ /^\s*('"$l_exclude"')/ && $2 !~/^('"$l_exclude2"')(\/|$)/){print $2}')
  if [ "${#a_file[@]}" -le 0 ]; then
    printf '%s\n' "" " - No world writable files exist on the local filesystem."
  else
    printf '%s\n' "" " - There are \"${#a_file[@]}\" World writable files on the system." \
    " - The following is a list of World writable files:" \
    "${a_file[@]}" " - end of list"
  fi
  if [ "${#a_dir[@]}" -le 0 ]; then
    printf '%s\n' "" \
    " - Sticky bit is set on world writable directories on the local filesystem."
  else
    printf '%s\n' "" " - There are \"${#a_dir[@]}\" World writable directories" \
    " without the sticky bit on the system." \
    " - The following is a list of World writable directories without the sticky bit:" \
    "${a_dir[@]}" " - end of list"
  fi
}

Result:

 - No world writable files exist on the local filesystem.

 - Sticky bit is set on world writable directories on the local filesystem.

Pass, no remediation needed.

7.1.12 Ensure No Files or Directories Without an Owner and a Group Exist #

#!/usr/bin/env bash
{
  a_output=() a_output2=() a_nouser=() a_nogroup=()
  a_path=(-path "*/containers/storage/*" -o -path "*/containerd/*" -o -path "*/kubelet/*" -o -path "/sys/*" -o -path "/snap/*" -o -path "/boot/efi/*")
  while IFS= read -r l_mount; do
    while IFS= read -r -d $'\0' l_file; do
      if [ -e "$l_file" ]; then
        while IFS=: read -r l_user l_group; do
          [ "$l_user" = "UNKNOWN" ] && a_nouser+=("$l_file")
          [ "$l_group" = "UNKNOWN" ] && a_nogroup+=("$l_file")
        done < <(stat -Lc '%U:%G' "$l_file")
      fi
    done < <(find "$l_mount" -mount -xdev \( "${a_path[@]}" \) -prune -o \(-type f -o -type d \) \( -nouser -o -nogroup \) -print0 2> /dev/null)
  done < <(findmnt -Dkerno fstype,target | awk '($1 !~/^\s*(nfs|proc|cifs|smb|vfat|iso9660|efivarfs|selinuxfs|ncpfs)/ && $2 !~/^(\/run|\/tmp|\/var\/tmp)(\/|$)/){print $2}')
  if [ "${#a_nouser[@]}" -le 0 ]; then
    a_output+=(" - No files or directories without an owner exist on the local filesystem.")
  else
    a_output2+=(" - There are \"${#a_nouser[@]}\" unowned files or directories on the system." \
    " - The following is a list of unowned files and/or directories:" \
    "${a_nouser[@]}" " - end of list")
  fi
  if [ "${#a_nogroup[@]}" -le 0 ]; then
    a_output+=(" - No files or directories without a group owner exist on the local filesystem.")
  else
    a_output2+=(" - There are \"${#a_nogroup[@]}\" ungrouped files or directories on the system." \
    " - The following is a list of ungrouped files and/or directories:" \
    "${a_nogroup[@]}" " - end of list")
  fi
  if [ "${#a_output2[@]}" -le 0 ]; then
    printf '%s\n' "" "- Audit Result:" " ** PASS **" "${a_output[@]}"
  else
    printf '%s\n' "" "- Audit Result:" " ** FAIL **" " * Reasons for audit failure *" "${a_output2[@]}" ""
    [ "${#a_output[@]}" -gt 0 ] && printf '%s\n' "- Correctly set:" "${a_output[@]}"
  fi
}

Result:

- Audit Result:
 ** PASS **
 - No files or directories without an owner exist on the local filesystem.
 - No files or directories without a group owner exist on the local filesystem.

7.1.13 Ensure SUID and SGID Files Are Reviewed #

This is a manual recommendation. The benchmark’s own guidance for this item is to review the files surfaced by the audit and confirm the integrity of the corresponding binaries.

#!/usr/bin/env bash
{
a_suid=(); a_sgid=()
while IFS= read -r l_mount; do
  while IFS= read -r -d $'\0' l_file; do
    if [ -e "$l_file" ]; then
      l_mode="$(stat -Lc '%#a' "$l_file")"
      [ $(( $l_mode & 04000 )) -gt 0 ] && a_suid+=("$l_file")
      [ $(( $l_mode & 02000 )) -gt 0 ] && a_sgid+=("$l_file")
    fi
  done < <(find "$l_mount" -xdev -type f \( -perm -2000 -o -perm -4000 \) -print0 2>/dev/null)
done < <(findmnt -Dkerno fstype,target,options | awk '($1 !~/^\s*(nfs|proc|smb|vfat|iso9660|efivarfs|selinuxfs)/ && $2 !~/^\/run\/user\// && $3 !~/noexec/ && $3 !~/nosuid/) {print $2}')
echo "SUID: ${#a_suid[@]} files, SGID: ${#a_sgid[@]} files"
printf '%s\n' "${a_suid[@]}" "${a_sgid[@]}"
}

Result:

- Audit Result:

 - List of "12" SUID executable files:
/usr/bin/gpasswd
/usr/bin/sudo
/usr/bin/newgrp
/usr/bin/chfn
/usr/bin/passwd
/usr/bin/chsh
/usr/bin/mount
/usr/bin/su
/usr/bin/at
/usr/bin/umount
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/openssh/ssh-keysign
 - end of list -

 - List of "7" SGID executable files:
/usr/bin/ssh-agent
/usr/bin/expiry
/usr/bin/dotlockfile
/usr/bin/crontab
/usr/bin/chage
/usr/bin/at
/usr/sbin/unix_chkpwd
 - end of list -

All 19 binaries are common, well-known system utilities, and none stand out as unexpected on sight.

To confirm the integrity of the listed binaries, an AIDE check was run against the full filesystem database. None of the 19 SUID or SGID binaries above appear among the added or changed entries in that run.

A separate custom script cross-checks ownership, permissions, and package integrity for the same set of binaries:

#!/usr/bin/env bash

{
  files=(
    /usr/bin/gpasswd
    /usr/bin/sudo
    /usr/bin/newgrp
    /usr/bin/chfn
    /usr/bin/passwd
    /usr/bin/chsh
    /usr/bin/mount
    /usr/bin/su
    /usr/bin/at
    /usr/bin/umount
    /usr/lib/dbus-1.0/dbus-daemon-launch-helper
    /usr/lib/openssh/ssh-keysign
    /usr/bin/ssh-agent
    /usr/bin/expiry
    /usr/bin/dotlockfile
    /usr/bin/crontab
    /usr/bin/chage
    /usr/sbin/unix_chkpwd
  )

  echo ">> Unowned files:"

  for f in "${files[@]}"; do
      dpkg -S "$f" >/dev/null 2>&1 || echo "$f" # exist code 1 when not found
  done

  echo
  echo ">> File permissions:"

  for f in "${files[@]}"; do
      stat -c '%n %a %U:%G' "$f"
  done

  echo
  echo ">> dpkg checks:"

  pkgs=$(dpkg -S "${files[@]}" 2>/dev/null | cut -d: -f1 | sort -u)

  for pkg in $pkgs; do
      echo "- $pkg:"
      dpkg -V "$pkg"
  done
}

Output:

>> Unowned files:

>> File permissions:
/usr/bin/gpasswd 4755 root:root
/usr/bin/sudo 4755 root:root
/usr/bin/newgrp 4755 root:root
/usr/bin/chfn 4755 root:root
/usr/bin/passwd 4755 root:root
/usr/bin/chsh 4755 root:root
/usr/bin/mount 4755 root:root
/usr/bin/su 4755 root:root
/usr/bin/at 6755 daemon:daemon
/usr/bin/umount 4755 root:root
/usr/lib/dbus-1.0/dbus-daemon-launch-helper 4754 root:messagebus
/usr/lib/openssh/ssh-keysign 4755 root:root
/usr/bin/ssh-agent 2755 root:_ssh
/usr/bin/expiry 2755 root:shadow
/usr/bin/dotlockfile 2755 root:mail
/usr/bin/crontab 2755 root:crontab
/usr/bin/chage 2755 root:shadow
/usr/bin/at 6755 daemon:daemon
/usr/sbin/unix_chkpwd 2755 root:shadow

>> dpkg checks:
- at:
- cron:
- dbus:
- liblockfile-bin:
- libpam-modules-bin:
- login:
- mount:
- openssh-client:
- passwd:
??5?????? c /etc/default/useradd
- sudo:
- util-linux:
??5?????? c /etc/pam.d/su

No file in the list is unowned by a package, and permissions and ownership across all 19 binaries look correct.

The dpkg -V pass across the owning packages flags two configuration files, /etc/default/useradd and /etc/pam.d/su, both marked as content-modified (5) but expected to be locally modified (c). Both are expected changes made earlier in the series: the former reflects the inactive password lock value set in 5.4.1.5, and the latter reflects the su command restriction configured in 5.2.7.

7.2 Local User and Group Settings #

This section covers 7.2.1 through 7.2.10 of the benchmark: consistency checks across /etc/passwd, /etc/shadow, and /etc/group, followed by home directory and dot file permission checks for local interactive users.

7.2.1 Ensure Accounts in /etc/passwd Use Shadowed Passwords #

# awk -F: '($2 != "x" ) { print "User: \"" $1 "\" is not set to shadowed passwords "}' /etc/passwd

No output, pass.

7.2.2 Ensure /etc/shadow Password Fields Are Not Empty #

# awk -F: '($2 == "" ) { print $1 " does not have a password "}' /etc/shadow

No output, pass.

7.2.3 Ensure All Groups in /etc/passwd Exist in /etc/group #

#!/usr/bin/env bash
{
  a_passwd_group_gid=("$(awk -F: '{print $4}' /etc/passwd | sort -u)")
  a_group_gid=("$(awk -F: '{print $3}' /etc/group | sort -u)")
  a_passwd_group_diff=("$(printf '%s\n' "${a_group_gid[@]}" "${a_passwd_group_gid[@]}" | sort | uniq -u)")
  while IFS= read -r l_gid; do
    awk -F: '($4 == '"$l_gid"') {print " - User: \"" $1 "\" has GID: \""$4 "\" which does not exist in /etc/group" }' /etc/passwd
  done < <(printf '%s\n' "${a_passwd_group_gid[@]}" "${a_passwd_group_diff[@]}" | sort | uniq -D | uniq)
  unset a_passwd_group_gid; unset a_group_gid; unset a_passwd_group_diff
}

No output, pass.

7.2.4 Ensure Shadow Group Is Empty #

# awk -F: '($1=="shadow") {print $NF}' /etc/group

# awk -F: '($4 == '"$(getent group shadow | awk -F: '{print $3}' | xargs)"') {print " - user: \"" $1 "\" primary group is the shadow group"}' /etc/passwd

No results, pass.

7.2.5 Ensure No Duplicate UIDs Exist #

#!/usr/bin/env bash
{
  while read -r l_count l_uid; do
    if [ "$l_count" -gt 1 ]; then
      echo -e "Duplicate UID: \"$l_uid\" Users: \"$(awk -F: '($3 == n) {
      print $1 }' n=$l_uid /etc/passwd | xargs)\""
    fi
  done < <(cut -f3 -d":" /etc/passwd | sort -n | uniq -c)
}

No output, pass.

7.2.6 Ensure No Duplicate GIDs Exist #

#!/usr/bin/env bash
{
  while read -r l_count l_gid; do
    if [ "$l_count" -gt 1 ]; then
      echo -e "Duplicate GID: \"$l_gid\" Groups: \"$(awk -F: '($3 == n) {
      print $1 }' n=$l_gid /etc/group | xargs)\""
    fi
  done < <(cut -f3 -d":" /etc/group | sort -n | uniq -c)
}

No output, pass.

7.2.7 Ensure No Duplicate User Names Exist #

#!/usr/bin/env bash
{
  while read -r l_count l_user; do
    if [ "$l_count" -gt 1 ]; then
      echo -e "Duplicate User: \"$l_user\" Users: \"$(awk -F: '($1 == n) {
      print $1 }' n=$l_user /etc/passwd | xargs)\""
    fi
  done < <(cut -f1 -d":" /etc/passwd | sort -n | uniq -c)
}

No output, pass.

7.2.8 Ensure No Duplicate Group Names Exist #

#!/usr/bin/env bash
{
  while read -r l_count l_group; do
    if [ "$l_count" -gt 1 ]; then
      echo -e "Duplicate Group: \"$l_group\" Groups: \"$(awk -F: '($1 == n) { print $1 }' n=$l_group /etc/group | xargs)\""
    fi
  done < <(cut -f1 -d":" /etc/group | sort -n | uniq -c)
}

No output, pass.

7.2.9 Ensure Local Interactive User Home Directories Are Configured #

#!/usr/bin/env bash
{
  a_output=() a_output2=() a_exists2=() a_mode2=() a_owner2=()
  l_valid_shells="^($( awk -F\/ '$NF != "nologin" {print}' /etc/shells | sed -rn '/^\//{s,/,\\\\/,g;p}' | paste -s -d '|' - ))$"
  l_mask='0027'; l_max="$( printf '%o' $(( 0777 & ~$l_mask)) )"
  l_users="$(awk -v pat="$l_valid_shells" -F: '$(NF) ~ pat { print $1 " " $(NF-1) }' /etc/passwd | wc -l)"
  [ "$l_users" -gt 10000 ] && printf '%s\n' "" " ** INFO **" \
  " $l_users Local interactive users found on the system" " This may be a long running check" " **********"
  while IFS=" " read -r l_user l_home; do
    if [ -d "$l_home" ]; then
      while IFS=: read -r l_own l_mode; do
        [ "$l_user" != "$l_own" ] && a_owner2+=(" - User: \"$l_user\" Home \"$l_home\" is owned by: \"$l_own\"")
        [ $(( $l_mode & $l_mask )) -gt 0 ] && a_mode2+=(" - User:
        \"$l_user\" Home \"$l_home\" is mode: \"$l_mode\"" \
        " should be mode: \"$l_max\" or more restrictive")
      done <<< "$(stat -Lc '%U:%#a' "$l_home")"
    else
      a_exists2+=(" - User: \"$l_user\" Home Directory: \"$l_home\" Doesn't exist")
    fi
  done <<< "$(awk -v pat="$l_valid_shells" -F: '$(NF) ~ pat { print $1 " "$(NF-1) }' /etc/passwd)"
  [ "${#a_exists2[@]}" -gt 0 ] && a_output2+=("${a_exists2[@]}") || \
  a_output+=(" - All interactive users home directories exist")
  [ "${#a_mode2[@]}" -gt 0 ] && a_output2+=("${a_mode2[@]}") || \
  a_output+=(" - All interactive users home directories are mode \"$l_max\" or more restrictive")
  [ "${#a_owner2[@]}" -gt 0 ] && a_output2+=("${a_owner2[@]}") || \
  a_output+=(" - All interactive users own their home directory")
  if [ "${#a_output2[@]}" -le 0 ]; then
    printf '%s\n' "" "- Audit Result:" " ** PASS **" "${a_output[@]}"
  else
    printf '%s\n' "" "- Audit Result:" " ** FAIL **" " - Reason(s) for audit failure:" "${a_output2[@]}"
    [ "${#a_output[@]}" -gt 0 ] && printf '%s\n' "- Correctly set:" "${a_output[@]}"
  fi
}

Result:

- Audit Result:
 ** PASS **
 - All interactive users home directories exist
 - All interactive users home directories are mode "750" or more restrictive
 - All interactive users own their home directory

7.2.10 Ensure Local Interactive User Dot Files Access Is Configured #

#!/usr/bin/env bash
{
  a_output2=(); a_output3=()
  l_maxsize="1000"
  l_valid_shells="^($( awk -F\/ '$NF != "nologin" {print}' /etc/shells | sed -rn '/^\//{s,/,\\\\/,g;p}' | paste -s -d '|' - ))$"
  a_user_and_home=()
  while read -r l_local_user l_local_user_home; do
    [[ -n "$l_local_user" && -n "$l_local_user_home" ]] && a_user_and_home+=("$l_local_user:$l_local_user_home")
  done <<< "$(awk -v pat="$l_valid_shells" -F: '$(NF) ~ pat { print $1 " " $(NF-1) }' /etc/passwd)"
  l_asize="${#a_user_and_home[@]}"
  [ "${#a_user_and_home[@]}" -gt "$l_maxsize" ] && printf '%s\n' "" " ** INFO **" \
  " - \"$l_asize\" Local interactive users found on the system" \
  " - This may be a long running check" ""
  file_access_chk()
  {
    a_access_out=()
    l_max="$( printf '%o' $(( 0777 & ~$l_mask)) )"
    if [ $(( $l_mode & $l_mask )) -gt 0 ]; then
      a_access_out+=(" - File: \"$l_hdfile\" is mode: \"$l_mode\" and should be mode: \"$l_max\" or more restrictive")
    fi
    if [[ ! "$l_owner" =~ ($l_user) ]]; then
      a_access_out+=(" - File: \"$l_hdfile\" owned by: \"$l_owner\" and should be owned by \"${l_user//|/ or }\"")
    fi
    if [[ ! "$l_gowner" =~ ($l_group) ]]; then
      a_access_out+=(" - File: \"$l_hdfile\" group owned by: \"$l_gowner\" and should be group owned by \"${l_group//|/ or }\"")
    fi
  }
  while IFS=: read -r l_user l_home; do
    a_dot_file=(); a_netrc=(); a_netrc_warn=(); a_bhout=(); a_hdirout=()
    if [ -d "$l_home" ]; then
      l_group="$(id -gn "$l_user" | xargs)";l_group="${l_group// /|}"
      while IFS= read -r -d $'\0' l_hdfile; do
        while read -r l_mode l_owner l_gowner; do
          case "$(basename "$l_hdfile")" in
            .forward | .rhost )
              a_dot_file+=(" - File: \"$l_hdfile\" exists") ;;
            .netrc )
              l_mask='0177'; file_access_chk
              if [ "${#a_access_out[@]}" -gt 0 ]; then
              a_netrc+=("${a_access_out[@]}")
              else
              a_netrc_warn+=(" - File: \"$l_hdfile\" exists")
              fi ;;
            .bash_history )
              l_mask='0177'; file_access_chk
              [ "${#a_access_out[@]}" -gt 0 ] && a_bhout+=("${a_access_out[@]}") ;;
            * )
              l_mask='0133'; file_access_chk
              [ "${#a_access_out[@]}" -gt 0 ] && a_hdirout+=("${a_access_out[@]}") ;;
          esac
        done < <(stat -Lc '%#a %U %G' "$l_hdfile") 
      done < <(find "$l_home" -xdev -type f -name '.*' -print0)
    fi
    if [[ "${#a_dot_file[@]}" -gt 0 || "${#a_netrc[@]}" -gt 0 || "${#a_bhout[@]}" -gt 0 || "${#a_hdirout[@]}" -gt 0 ]]; then
      a_output2+=(" - User: \"$l_user\" Home Directory: \"$l_home\"" "${a_dot_file[@]}" "${a_netrc[@]}" "${a_bhout[@]}" "${a_hdirout[@]}")
    fi
    [ "${#a_netrc_warn[@]}" -gt 0 ] && a_output3+=(" - User: \"$l_user\" Home Directory: \"$l_home\"" "${a_netrc_warn[@]}")
  done <<< "$(printf '%s\n' "${a_user_and_home[@]}")"
  if [ "${#a_output2[@]}" -le 0 ]; then
    [ "${#a_output3[@]}" -gt 0 ] && printf '%s\n' " ** WARNING **" "${a_output3[@]}"
    printf '%s\n' "- Audit Result:" " ** PASS **"
  else
    printf '%s\n' "- Audit Result:" " ** FAIL **" " - * Reasons for audit failure *:" "${a_output2[@]}" ""
    [ "${#a_output3[@]}" -gt 0 ] && printf '%s\n' " ** WARNING **" "${a_output3[@]}"
  fi
}

Result:

- Audit Result:
 ** PASS **

A Lynis rerun shows the hardening index unchanged at 74.