#!/bin/bash ## Copyright (C) 2012 - 2024 ENCRYPTED SUPPORT LP ## See the file COPYING for copying conditions. ## https://forums.whonix.org/t/disable-suid-binaries/7706 ## https://forums.whonix.org/t/re-mount-home-and-other-with-noexec-and-nosuid-among-other-useful-mount-options-for-better-security/7707 ## dpkg-statoverride does not support end-of-options ("--"). set -o errexit -o nounset -o pipefail # shellcheck disable=SC1091 source /usr/libexec/helper-scripts/safe_echo.sh # shellcheck disable=SC2034 log_level=notice # shellcheck disable=SC1091 source /usr/libexec/helper-scripts/log_run_die.sh echo_wrapper_ignore() { if test "${1}" = "verbose"; then shift log notice "Executing: $*" else shift fi "$@" 2>/dev/null || true } echo_wrapper_audit() { local return_code if test "${1}" = "verbose"; then shift log notice "Executing: $*" else shift fi return_code=0 "$@" || { return_code="$?" exit_code=203 log error "Command '$*' failed with exit code '${return_code}'! calling function name: '${FUNCNAME[1]}'" >&2 } } ## Some tools may fail on newlines and even variable assignment to array may ## fail if a variable that will be assigned to an array element contains ## characters that are used as delimiters. block_newlines() { local newline_variable newline_value newline_variable="${1:-}" newline_value="${2:-}" ## dpkg-statoverride: error: path may not contain newlines if [[ "${newline_value}" != "${newline_value//$'\n'/NEWLINE}" ]]; then log warn "Skipping ${newline_variable} that contains newlines: '${newline_value}'" >&2 return 1 fi } output_stat() { local file_name file_name="${1:-}" if test -z "${file_name}"; then log error "File name is empty. file_name: '${file_name}'" >&2 return 1 fi block_newlines file "${file_name}" declare -a arr local file_name_from_stat stat_output stat_output_newlined if ! stat_output="$(stat -L --format="%a${delimiter}%U${delimiter}%G${delimiter}%n${delimiter}" -- "${file_name}")"; then log error "Failed to run 'stat' on file: '${file_name}'!" >&2 return 1 fi if [ "$stat_output" = "" ]; then log error "stat_output is empty. File name: '${file_name}' Stat output: '${stat_output}' stat_output_newlined: '${stat_output_newlined}' line: '${processed_config_line}' " >&2 return 1 fi stat_output_newlined="$(printf '%s\n' "${stat_output//${delimiter}/$'\n'}")" if test "${stat_output_newlined}" = ""; then log error "stat_output_newlined is empty. File name: '${file_name}' Stat output: '${stat_output}' stat_output_newlined: '${stat_output_newlined}' line: '${processed_config_line}' " >&2 return 1 fi readarray -t arr <<< "${stat_output_newlined}" if test "${#arr[@]}" = 0; then log error "Array length is 0. File name: '${file_name}' Stat output: '${stat_output}' stat_output_newlined: '${stat_output_newlined}' line: '${processed_config_line}' " >&2 return 1 fi existing_mode="${arr[0]}" existing_owner="${arr[1]}" existing_group="${arr[2]}" file_name_from_stat="${arr[3]}" if [ ! "$file_name" = "$file_name_from_stat" ]; then log error "\ File name is different from file name received from stat: File name: '${file_name}' File name from stat: '${file_name_from_stat}' line: '${processed_config_line}' " >&2 return 1 fi if test -z "${existing_mode}"; then log error "Existing mode is empty. Stat output: '${stat_output}', line: '${processed_config_line}'" >&2 return 1 fi if test -z "${existing_owner}"; then log error "Existing owner is empty. Stat output: '${stat_output}', line: '${processed_config_line}'" >&2 return 1 fi if test -z "${existing_group}"; then log error "Existing group is empty. Stat output: '${stat_output}', line: '${processed_config_line}'" >&2 return 1 fi } print_usage(){ safe_echo "Usage: ${0##*/} enable ${0##*/} disable [FILE|all] Examples: ${0##*/} enable ${0##*/} disable all ${0##*/} disable /usr/bin/newgrp" >&2 } ## TODO: Validate input before you blindly trust it! add_to_policy() { local file_name file_mode file_owner file_group updated_entry policy_idx \ file_capabilities file_name="${1:-}" file_mode="${2:-}" file_owner="${3:-}" file_group="${4:-}" file_capabilities="${5:-}" updated_entry=false for (( policy_idx=0; policy_idx < ${#policy_file_list[@]}; policy_idx++ )); do if [ "${policy_file_list[policy_idx]}" = "${file_name}" ]; then policy_mode_list[policy_idx]="${file_mode}" policy_user_owner_list[policy_idx]="${file_owner}" policy_group_owner_list[policy_idx]="${file_group}" policy_capability_list[policy_idx]="${file_capabilities}" updated_entry=true break fi done if [ "${updated_entry}" != 'true' ]; then policy_file_list+=( "${file_name}" ) policy_mode_list+=( "${file_mode}" ) policy_user_owner_list+=( "${file_owner}" ) policy_group_owner_list+=( "${file_group}" ) policy_capability_list+=( "${file_capabilities}" ) fi } check_nosuid_whitelist() { local target_file match_white_list_entry target_file="${1:-}" ## Handle whitelists, if we're supposed to if [ "${whitelists_disable_all}" = 'false' ]; then ## literal matching is intentional here # shellcheck disable=SC2076 if ! [[ " ${policy_disable_white_list[*]} " =~ " ${target_file} " ]]; then ## literal matching is intentional here too # shellcheck disable=SC2076 if [[ " ${policy_exact_white_list[*]} " =~ " ${target_file} " ]]; then return 1 fi for match_white_list_entry in "${policy_match_white_list[@]:-}"; do if safe_echo "${target_file}" \ | grep --quiet --fixed-strings -- "${match_white_list_entry}"; then return 1 fi done fi fi return 0 } load_early_nosuid_policy() { local target_file find_list_item target_file="${1:-}" # shellcheck disable=SC2185 while IFS="" read -r -d "" find_list_item; do check_nosuid_whitelist "${find_list_item}" || continue ## sets: ## exiting_mode ## existing_owner ## existing_group output_stat "${find_list_item}" ## -h file True if file is a symbolic Link. ## -u file True if file has its set-user-id bit set. ## -g file True if file has its set-group-id bit set. if [ -h "${find_list_item}" ]; then ## https://forums.whonix.org/t/disable-suid-binaries/7706/14 log info "Skip symlink: '${find_list_item}'" continue fi if [ -d "${find_list_item}" ]; then log info "Skip directory: '${find_list_item}'" continue fi ## Trim off the most significant digit of the mode, this discards S(U|G)ID ## bits (and the sticky bit too but that doesn't matter on Linux) ## ## Actually, the old behavior is better here. local new_mode # new_mode="${existing_mode:1}" new_mode='744' add_to_policy "${find_list_item}" "${new_mode}" "${existing_owner}" \ "${existing_group}" done < <(safe_echo_nonewline "${target_file}" | find -files0-from - -perm /u=s,g=s -print0) } load_late_nosuid_policy() { local target_file state_idx state_file_item state_user_owner_item \ state_group_owner_item target_file="${1:-}" for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do state_file_item="${state_file_list[state_idx]}" state_user_owner_item="${state_user_owner_list[state_idx]}" state_group_owner_item="${state_group_owner_list[state_idx]}" check_nosuid_whitelist "${state_file_item}" || continue if [[ ${state_file_item} == ${target_file}* ]]; then if [ -h "${state_file_item}" ]; then ## https://forums.whonix.org/t/disable-suid-binaries/7706/14 log info "Skip symlink: '${state_file_item}'" continue fi if [ -d "${state_file_item}" ]; then log info "Skip directory: '${state_file_item}'" continue fi local new_mode new_mode='744' add_to_policy "${state_file_item}" "${new_mode}" \ "${state_user_owner_item}" "${state_group_owner_item}" fi done } load_state() { ## Config format: ## path options ## where options is one of: ## user_owner group_owner filemode [capability-setting] ## [nosuid|exactwhitelist|matchwhitelist|disablewhitelist] local config_file line bit_list file_path policy_nosuid_file_item ## Load configuration, deferring whitelist handling until later for config_file in \ /usr/lib/permission-hardener.d/*.conf \ /etc/permission-hardener.d/*.conf \ /usr/local/etc/permission-hardener.d/*.conf \ /etc/permission-hardening.d/*.conf \ /usr/local/etc/permission-hardening.d/*.conf do if [ ! -f "${config_file}" ]; then continue fi while read -r line; do if [ -z "${line}" ]; then true 'DEBUG: line is empty. Skipping.' continue fi if [[ "${line}" =~ ^\s*# ]]; then continue fi if ! [[ "${line}" =~ [0-9a-zA-Z/] ]]; then exit_code=200 log error "Line contains invalid characters: '${line}'" >&2 ## Safer to exit with error in this case. ## https://forums.whonix.org/t/disable-suid-binaries/7706/59 exit "${exit_code}" fi if [ "${line}" = 'whitelists_disable_all=true' ]; then whitelists_disable_all=true log info "whitelists_disable_all=true" continue fi processed_config_line="${line}" IFS=' ' read -r -a bit_list <<< "${line}" if (( ${#bit_list[@]} < 2 )) \ || (( ${#bit_list[@]} > 5 )) \ || (( ${#bit_list[@]} == 3 )); then exit_code=200 log error "Line contains an invalid number of fields: '${line}'" >&2 exit "${exit_code}" fi # Strip trailing slash if appropriate bit_list[0]="${bit_list[0]%/}" file_path="${bit_list[0]}" case "${bit_list[1]}" in 'exactwhitelist') [ ! -e "${file_path}" ] && continue policy_exact_white_list+=( "${file_path}" ) continue ;; 'matchwhitelist') policy_match_white_list+=( "${file_path}" ) continue ;; 'disablewhitelist') policy_disable_white_list+=( "${file_path}" ) continue ;; 'nosuid') [ ! -e "${file_path}" ] && continue policy_nosuid_file_list+=( "${file_path}" ) ;; *) [ ! -e "${file_path}" ] && continue add_to_policy "${bit_list[@]}" ;; esac done < "${config_file}" done ## We have to handle nosuid files at the end since the whitelist arrays need ## built first. for policy_nosuid_file_item in "${policy_nosuid_file_list[@]}"; do load_early_nosuid_policy "${policy_nosuid_file_item}" done local line bit_list policy_file_item ## Load the state file from disk if [ -f "${state_file}" ]; then while read -r line; do read -r -a bit_list <<< "${line}" if (( ${#bit_list[@]} != 4 )); then log info "Invalid number of fields in state file line: '${line}'. Skipping." continue fi state_user_owner_list+=( "${bit_list[0]}" ) state_group_owner_list+=( "${bit_list[1]}" ) state_mode_list+=( "${bit_list[2]}" ) state_file_list+=( "${bit_list[3]}" ) done < "${state_file}" fi ## Find any files in the policy that don't already have a matching file in ## the state. Add those files to the state, and save them to the state file ## as well. for policy_file_item in "${policy_file_list[@]}"; do # shellcheck disable=SC2076 if [[ " ${state_file_list[*]} " =~ " ${policy_file_item} " ]]; then continue fi output_stat "${policy_file_item}" state_file_list+=( "${policy_file_item}" ) state_user_owner_list+=( "${existing_owner}" ) state_group_owner_list+=( "${existing_group}" ) state_mode_list+=( "${existing_mode}" ) # shellcheck disable=SC2086 echo_wrapper_audit silent dpkg-statoverride \ ${dpkg_admindir_parameter_existing_mode} \ --add "${existing_owner}" "${existing_group}" "${existing_mode}" \ "${policy_file_item}" done for policy_nosuid_file_item in "${policy_nosuid_file_list[@]}"; do load_late_nosuid_policy "${policy_nosuid_file_item}" done } apply_policy() { local policy_idx did_state_update state_idx ## Modify the in-memory state so that all items that the policy affects match ## the policy. DO NOT save these changes to the state file! for (( policy_idx=0; policy_idx < ${#policy_file_list[@]}; policy_idx++ )); do did_state_update=false for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do if [ "${state_file_list[state_idx]}" = "${policy_file_list[policy_idx]}" ]; then state_user_owner_list[state_idx]="${policy_user_owner_list[policy_idx]}" state_group_owner_list[state_idx]="${policy_group_owner_list[policy_idx]}" state_mode_list[state_idx]="${policy_mode_list[policy_idx]}" did_state_update=true break fi done if [ "${did_state_update}" = 'false' ]; then exit_code=206 log error "File exists in policy but not in state! File: '${policy_file_list[policy_idx]}'" exit "${exit_code}" fi done } commit_policy() { local policy_idx state_idx state_file_item \ state_user_owner_item state_group_owner_item \ state_mode_item orig_main_statoverride_db orig_new_statoverride_db \ policy_file_item policy_capability_item ## Check each file on the filesystem against the state, and update it if the ## state does not match. Also ensure the consistency of the new_mode database ## so that people can compare the original permissions of files with the new ## permissions. orig_main_statoverride_db="$(dpkg-statoverride --list)" || true # shellcheck disable=SC2086 orig_new_statoverride_db="$(dpkg-statoverride ${dpkg_admindir_parameter_new_mode} --list)" || true for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do state_file_item="${state_file_list[state_idx]}" state_user_owner_item="${state_user_owner_list[state_idx]}" state_group_owner_item="${state_group_owner_list[state_idx]}" state_mode_item="${state_mode_list[state_idx]}" ## Get rid of leading zeros, stat doesn't output them due to how we use it. ## Using BASH_REMATCH is faster than sed. We capture all leading zeros into ## one group, and the rest of the string into a second group. The second ## group is the string we want. BASH_REMATCH[0] is the entire string, ## BASH_REMATCH[1] is the first match that we want to discard, and ## BASH_REMATCH[2] is the desired second group. [[ "${state_mode_item}" =~ ^(0*)(.*) ]] || true; state_mode_item="${BASH_REMATCH[2]}" output_stat "${state_file_item}" if [ "${existing_owner}" != "${state_user_owner_item}" ] \ || [ "${existing_group}" != "${state_group_owner_item}" ] \ || [ "${existing_mode}" != "${state_mode_item}" ]; then if ! grep --quiet --fixed-strings -- "${state_user_owner_item}:" "${store_dir}/private/passwd"; then log error "Owner from config does not exist: '${state_user_owner_item}'" >&2 continue fi if ! grep --quiet --fixed-strings -- "${state_group_owner_item}:" "${store_dir}/private/group"; then log error "Group from config does not exist: '${state_group_owner_item}'" >&2 continue fi # Remove and reapply in main list if grep --quiet --fixed-strings \ -- "${state_file_item}" <<< "${orig_main_statoverride_db}"; then echo_wrapper_ignore silent dpkg-statoverride --remove \ "${state_file_item}" fi echo_wrapper_audit verbose dpkg-statoverride --add --update \ "${state_user_owner_item}" "${state_group_owner_item}" \ "${state_mode_item}" "${state_file_item}" # Update item in secondary list if grep --quiet --fixed-strings \ -- "${state_file_item}" <<< "${orig_new_statoverride_db}"; then # shellcheck disable=SC2086 echo_wrapper_ignore silent dpkg-statoverride \ ${dpkg_admindir_parameter_new_mode} --remove \ "${state_file_item}" fi # shellcheck disable=SC2086 echo_wrapper_audit verbose dpkg-statoverride \ ${dpkg_admindir_parameter_new_mode} --add \ "${state_user_owner_item}" "${state_group_owner_item}" \ "${state_mode_item}" "${state_file_item}" fi done ## Apply capability hardening, dpkg-statoverride can't handle this so we have ## to do this manually for (( policy_idx=0; policy_idx < ${#policy_file_list[@]}; policy_idx++ )); do policy_file_item="${policy_file_list[policy_idx]}" policy_capability_item="${policy_capability_list[policy_idx]}" if [ -z "${policy_capability_item}" ]; then continue fi if [ "${policy_capability_item}" = 'none' ]; then echo_wrapper_ignore verbose setcap -r "${policy_file_item}" if [ -n "$(getcap -- "${policy_file_item}")" ]; then exit_code=205 log error \ "Removing capabilities failed. File: '${policy_file_item}'" >&2 continue fi else if ! capsh --print \ | grep --fixed-strings -- "Bounding set" \ | grep --quiet -- "${policy_capability_item}"; then log error \ "Capability from config does not exist: '${policy_capability_item}'" \ >&2 continue fi ## feature request: dpkg-statoverride: support for capabilities ## https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=502580 echo_wrapper_audit verbose setcap "${policy_capability_item}+ep" \ -- "${policy_file_item}" fi done } undo_policy_for_file() { local undo_file state_idx state_file_item did_undo \ undo_all verbose orig_main_statoverride_db orig_new_statoverride_db \ state_user_owner_item state_group_owner_item state_mode_item undo_file="${1}" undo_all=false verbose='--verbose' if [ "${undo_file}" = 'all' ]; then undo_all=true verbose='' fi if [ ! -f "${state_file}" ]; then true 'DEBUG: State file does not exist, hardening was not applied before.' return 0 fi did_undo=false for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do state_file_item="${state_file_list[state_idx]}" if [ "${undo_all}" = 'true' ]; then undo_file="${state_file_item}" fi if [ "${state_file_item}" = "${undo_file}" ]; then orig_main_statoverride_db="$(dpkg-statoverride --list)" || true # shellcheck disable=SC2086 orig_new_statoverride_db="$(dpkg-statoverride ${dpkg_admindir_parameter_new_mode} --list)" || true if grep --quiet --fixed-strings \ -- "${undo_file}" <<< "${orig_main_statoverride_db}"; then echo_wrapper_ignore silent dpkg-statoverride --remove \ "${undo_file}" fi if grep --quiet --fixed-strings \ -- "${undo_file}" <<< "${orig_new_statoverride_db}"; then # shellcheck disable=SC2086 echo_wrapper_ignore silent dpkg-statoverride \ ${dpkg_admindir_parameter_new_mode} --remove \ "${undo_file}" fi if [ -e "${undo_file}" ]; then state_user_owner_item="${state_user_owner_list[state_idx]}" state_group_owner_item="${state_group_owner_list[state_idx]}" state_mode_item="${state_mode_list[state_idx]}" chown ${verbose} "${state_user_owner_item}:${state_group_owner_item}" \ "${undo_file}" || exit_code=202 ## chmod need to be run after chown since chown removes suid. chmod ${verbose} "${state_mode_item}" "${undo_file}" || exit_code=203 else log info "File does not exist: '${undo_file}'" fi did_undo=true break fi done if ! [[ "${did_undo}" = 'false' ]]; then log info "The specified file is not hardened, leaving unchanged. File '${undo_file}' has not been removed from SUID Disabler and Permission Hardener during this invocation. This is expected if no policy was ever applied to the file before. This program expects the full path to the file. Example: $0 disable /usr/bin/newgrp # absolute path: works $0 disable newgrp # relative path: does not work To remove all: $0 disable all This change might not be permanent. For full instructions, see: https://www.kicksecure.com/wiki/SUID_Disabler_and_Permission_Hardener To view list of changed by SUID Disabler and Permission Hardener: https://www.kicksecure.com/wiki/SUID_Disabler_and_Permission_Hardener#View_List_of_Permissions_Changed_by_SUID_Disabler_and_Permission_Hardener For re-enabling any specific SUID binary: https://www.kicksecure.com/wiki/SUID_Disabler_and_Permission_Hardener#Re-Enable_Specific_SUID_Binaries For completely disabling SUID Disabler and Permission Hardener: https://www.kicksecure.com/wiki/SUID_Disabler_and_Permission_Hardener#Disable_SUID_Disabler_and_Permission_Hardener" fi } print_columns() { local format_str bogus_str format_str='' for bogus_str in "$@"; do format_str="${format_str}%s\t" done format_str="${format_str}\n" # Using a dynamically generated format string on purpose. # shellcheck disable=SC2059 printf "${format_str}" "$@" } print_policy() { local policy_idx print_columns 'File' 'User' 'Group' 'Mode' 'Capabilities' for (( policy_idx=0; policy_idx < ${#policy_file_list[@]}; policy_idx++ )); do print_columns \ "${policy_file_list[policy_idx]}" \ "${policy_user_owner_list[policy_idx]}" \ "${policy_group_owner_list[policy_idx]}" \ "${policy_mode_list[policy_idx]}" \ "${policy_capability_list[policy_idx]}" done } print_state() { local state_idx print_columns 'File' 'User' 'Group' 'Mode' for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do print_columns \ "${state_file_list[state_idx]}" \ "${state_user_owner_list[state_idx]}" \ "${state_group_owner_list[state_idx]}" \ "${state_mode_list[state_idx]}" done } ## Constants store_dir="/var/lib/permission-hardener" state_file="${store_dir}/existing_mode/statoverride" dpkg_admindir_parameter_existing_mode="--admindir ${store_dir}/existing_mode" dpkg_admindir_parameter_new_mode="--admindir ${store_dir}/new_mode" delimiter="#permission-hardener-delimiter#" ## Global variables policy_file_list=() policy_user_owner_list=() policy_group_owner_list=() policy_mode_list=() policy_capability_list=() policy_exact_white_list=() policy_match_white_list=() policy_disable_white_list=() policy_nosuid_file_list=() state_file_list=() state_user_owner_list=() state_group_owner_list=() state_mode_list=() whitelists_disable_all=false existing_mode='' existing_owner='' existing_group='' processed_config_line='' exit_code=0 ## Setup and sanity checking if [ "$(id -u)" != '0' ]; then log error "Not running as root, aborting." exit 1 fi mkdir --parents "${store_dir}/private" mkdir --parents "${store_dir}/existing_mode" mkdir --parents "${store_dir}/new_mode" touch "${store_dir}/private/passwd" chmod og-rwx "${store_dir}/private/passwd" touch "${store_dir}/private/group" chmod og-rwx "${store_dir}/private/group" getent passwd | sponge -- "${store_dir}/private/passwd" getent group | sponge -- "${store_dir}/private/group" echo_wrapper_audit silent which capsh getcap setcap stat find \ dpkg-statoverride getent grep 1>/dev/null ## Command parsing and execution case "${1:-}" in enable) shift load_state apply_policy commit_policy ;; disable) shift case "${1:-}" in "") print_usage exit 1 ;; *) load_state undo_policy_for_file "${1}" ;; esac ;; print-policy) load_state print_policy ;; print-state) load_state print_state ;; print-policy-applied-state) load_state apply_policy print_state ;; -h|--help) print_usage exit 0 ;; *) print_usage exit 1 ;; esac if test "${exit_code}" != "0"; then log error "Exiting with non-zero exit code: '${exit_code}'" >&2 fi exit "${exit_code}"