Merge pull request #10425 from acrogenesis/security/root-owned-sleep-hooks
Harden ownership of installed sleep hooks
This commit is contained in:
8 files changed
+1189
-21
No files matched your search
@@ -0,0 +1,303 @@
|
||||
echo "Repair user-owned system-sleep hooks and hybrid GPU service configuration"
|
||||
|
||||
system_sleep_dir=/usr/lib/systemd/system-sleep
|
||||
supergfxd_drop_in=/etc/systemd/system/supergfxd.service.d/delay-start.conf
|
||||
quarantine_root=/var/lib/omarchy/migrations/1788662350-system-sleep
|
||||
reload_needed_marker=/var/lib/omarchy/migrations/1788662350-systemd-reload-needed
|
||||
keyboard_source="$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight"
|
||||
force_igpu_source="$OMARCHY_PATH/default/systemd/system-sleep/force-igpu"
|
||||
supergfxd_source="$OMARCHY_PATH/default/systemd/system/supergfxd.service.d/delay-start.conf"
|
||||
legacy_keyboard_sha256=f313a81e47401f0d38b8602e5997f52c5286d5e97f74027564ddd515b3d16511
|
||||
legacy_force_igpu_sha256=d604e7c4903829563e45fc52188fc5602c3f1bc66e247f0a2cc0a974ed6e57db
|
||||
|
||||
as_root() {
|
||||
if (( EUID == 0 )); then
|
||||
"$@"
|
||||
else
|
||||
sudo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
path_is_root_controlled() {
|
||||
local path="$1"
|
||||
local current=/ component candidate file_mode link metadata part status uid gid mode
|
||||
local missing_depth=0 symlink_count=0
|
||||
local -a pending resolved link_components
|
||||
|
||||
[[ $path == /* ]] || return 1
|
||||
IFS=/ read -r -a pending <<<"$path"
|
||||
# A non-root group is harmless when neither it nor everyone else can write.
|
||||
# Resolve symlinks component by component so an indirect link cannot hide an
|
||||
# intermediate directory controlled by an unprivileged user.
|
||||
metadata=$(path_metadata /) || return 1
|
||||
read -r file_mode uid gid mode <<<"$metadata"
|
||||
(( uid == 0 && (8#$mode & 8#022) == 0 )) || return 1
|
||||
|
||||
while ((${#pending[@]})); do
|
||||
component=${pending[0]}
|
||||
pending=("${pending[@]:1}")
|
||||
[[ -n $component ]] || continue
|
||||
[[ $component == "." ]] && continue
|
||||
|
||||
if [[ $component == ".." ]]; then
|
||||
if ((${#resolved[@]})); then
|
||||
unset 'resolved[-1]'
|
||||
fi
|
||||
|
||||
current=/
|
||||
for part in "${resolved[@]}"; do
|
||||
if [[ $current == "/" ]]; then
|
||||
current="/$part"
|
||||
else
|
||||
current="$current/$part"
|
||||
fi
|
||||
done
|
||||
if (( missing_depth > 0 && ${#resolved[@]} < missing_depth )); then
|
||||
missing_depth=0
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ $current == "/" ]]; then
|
||||
candidate="/$component"
|
||||
else
|
||||
candidate="$current/$component"
|
||||
fi
|
||||
|
||||
if (( missing_depth > 0 )); then
|
||||
# The first missing component makes descendants inactive today, but keep
|
||||
# consuming the lexical suffix. A later .. can escape back into an
|
||||
# existing user-controlled path that would become active if an
|
||||
# administrator creates the missing directory.
|
||||
resolved+=("$component")
|
||||
current=$candidate
|
||||
continue
|
||||
elif metadata=$(path_metadata "$candidate"); then
|
||||
read -r file_mode uid gid mode <<<"$metadata"
|
||||
else
|
||||
status=$?
|
||||
if (( status == 2 )); then
|
||||
resolved+=("$component")
|
||||
current=$candidate
|
||||
missing_depth=${#resolved[@]}
|
||||
continue
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if (( (16#$file_mode & 16#f000) == 16#a000 )); then
|
||||
((++symlink_count <= 40)) || return 1
|
||||
link=$(readlink_with_privilege "$candidate") || return 1
|
||||
IFS=/ read -r -a link_components <<<"$link"
|
||||
pending=("${link_components[@]}" "${pending[@]}")
|
||||
if [[ $link == /* ]]; then
|
||||
resolved=()
|
||||
current=/
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
(( uid == 0 && (8#$mode & 8#022) == 0 )) || return 1
|
||||
resolved+=("$component")
|
||||
current=$candidate
|
||||
done
|
||||
}
|
||||
|
||||
path_metadata() {
|
||||
local path="$1"
|
||||
local metadata parent
|
||||
|
||||
if /usr/bin/stat -c '%f %u %g %a' -- "$path" 2>/dev/null; then
|
||||
return 0
|
||||
elif [[ ! -e $path && ! -L $path ]]; then
|
||||
parent=${path%/*}
|
||||
[[ -n $parent ]] || parent=/
|
||||
# Avoid asking for sudo for ordinary ENOENT. If the parent is searchable,
|
||||
# the absence is conclusive; an inaccessible root-only chain still needs a
|
||||
# privileged metadata check so safe administrator symlinks are preserved.
|
||||
[[ -x $parent ]] && return 2
|
||||
if metadata=$(as_root /usr/bin/stat -c '%f %u %g %a' -- "$path" 2>/dev/null); then
|
||||
printf '%s\n' "$metadata"
|
||||
return 0
|
||||
elif as_root /usr/bin/test -x "$parent"; then
|
||||
# The privileged probe could search the protected parent, so stat's
|
||||
# failure identifies a target that does not exist yet.
|
||||
return 2
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
as_root /usr/bin/stat -c '%f %u %g %a' -- "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
readlink_with_privilege() {
|
||||
local path="$1"
|
||||
|
||||
if /usr/bin/readlink -- "$path" 2>/dev/null; then
|
||||
return 0
|
||||
else
|
||||
as_root /usr/bin/readlink -- "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
privileged_entry_is_safe() {
|
||||
local path="$1"
|
||||
|
||||
path_is_root_controlled "$path"
|
||||
}
|
||||
|
||||
file_matches_source() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
|
||||
[[ -f $destination && ! -L $destination ]] || return 1
|
||||
|
||||
if [[ -r $destination ]]; then
|
||||
/usr/bin/cmp -s -- "$source" "$destination"
|
||||
else
|
||||
as_root /usr/bin/cmp -s -- "$source" "$destination"
|
||||
fi
|
||||
}
|
||||
|
||||
file_matches_sha256() {
|
||||
local destination="$1"
|
||||
local expected="$2"
|
||||
local digest
|
||||
|
||||
[[ -f $destination && ! -L $destination ]] || return 1
|
||||
if [[ -r $destination ]]; then
|
||||
digest=$(/usr/bin/sha256sum -- "$destination") || return 1
|
||||
else
|
||||
digest=$(as_root /usr/bin/sha256sum -- "$destination") || return 1
|
||||
fi
|
||||
[[ ${digest%% *} == "$expected" ]]
|
||||
}
|
||||
|
||||
safe_stage_path() {
|
||||
local stage="$1"
|
||||
local destination="$2"
|
||||
local prefix suffix
|
||||
|
||||
prefix="${destination%/*}/.${destination##*/}.omarchy."
|
||||
[[ $stage == "$prefix"* ]] || return 1
|
||||
suffix=${stage#"$prefix"}
|
||||
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
|
||||
}
|
||||
|
||||
install_root_file() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local mode="$3"
|
||||
local stage
|
||||
|
||||
stage=$(as_root /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1
|
||||
safe_stage_path "$stage" "$destination" || return 1
|
||||
|
||||
if as_root /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" &&
|
||||
as_root /usr/bin/mv -Tf -- "$stage" "$destination"; then
|
||||
return 0
|
||||
else
|
||||
safe_stage_path "$stage" "$destination" && as_root /usr/bin/rm -f -- "$stage"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
preserve_unsafe_customization() {
|
||||
local path="$1"
|
||||
local label="$2"
|
||||
local backup_dir backup
|
||||
|
||||
if ! as_root /usr/bin/install -d -m 0700 -o root -g root "$quarantine_root"; then
|
||||
echo "Could not create the root-only system-sleep quarantine at $quarantine_root" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! backup_dir=$(as_root /usr/bin/mktemp -d -- "$quarantine_root/${label}.XXXXXX"); then
|
||||
echo "Could not reserve a quarantine path for $path" >&2
|
||||
return 1
|
||||
fi
|
||||
backup="$backup_dir/original"
|
||||
|
||||
if as_root /usr/bin/cp -a --no-dereference -T -- "$path" "$backup"; then
|
||||
printf '%s\n' "$backup"
|
||||
else
|
||||
as_root /usr/bin/rm -rf -- "$backup_dir"
|
||||
echo "Could not preserve unsafe custom content from $path before repairing it" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
repair_unsafe_privileged_entry() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local mode="$3"
|
||||
local label="$4"
|
||||
local legacy_sha256="${5:-}"
|
||||
local backup current_mode
|
||||
|
||||
[[ -e $destination || -L $destination ]] || return 0
|
||||
[[ -f $destination || -L $destination ]] || return 0
|
||||
|
||||
if file_matches_source "$source" "$destination"; then
|
||||
current_mode=$(/usr/bin/stat -c '%a' -- "$destination" 2>/dev/null) ||
|
||||
current_mode=$(as_root /usr/bin/stat -c '%a' -- "$destination") || return 1
|
||||
if privileged_entry_is_safe "$destination" && [[ $current_mode == "${mode#0}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
elif [[ -n $legacy_sha256 ]] && file_matches_sha256 "$destination" "$legacy_sha256"; then
|
||||
:
|
||||
else
|
||||
privileged_entry_is_safe "$destination" && return 0
|
||||
backup=$(preserve_unsafe_customization "$destination" "$label") || return 1
|
||||
fi
|
||||
|
||||
if install_root_file "$source" "$destination" "$mode"; then
|
||||
if [[ -n ${backup:-} ]]; then
|
||||
echo "Preserved unsafe custom content from $destination at $backup for administrator review" >&2
|
||||
fi
|
||||
else
|
||||
if [[ -n ${backup:-} ]]; then
|
||||
echo "Preserved unsafe custom content from $destination at $backup, but could not repair the active path" >&2
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Replace rather than chown an unsafe destination: its current owner may have
|
||||
# already changed the contents or kept a writable file descriptor open. The
|
||||
# root-owned staging inode makes the final rename an atomic trust transition.
|
||||
repair_unsafe_privileged_entry "$keyboard_source" \
|
||||
"$system_sleep_dir/keyboard-backlight" 0755 keyboard-backlight "$legacy_keyboard_sha256"
|
||||
|
||||
force_igpu="$system_sleep_dir/force-igpu"
|
||||
repair_unsafe_privileged_entry "$force_igpu_source" "$force_igpu" 0755 force-igpu "$legacy_force_igpu_sha256"
|
||||
|
||||
systemd_reload_needed=false
|
||||
if [[ -e $reload_needed_marker || -L $reload_needed_marker ]]; then
|
||||
systemd_reload_needed=true
|
||||
fi
|
||||
|
||||
if [[ -e $supergfxd_drop_in || -L $supergfxd_drop_in ]]; then
|
||||
if ! privileged_entry_is_safe "$supergfxd_drop_in"; then
|
||||
# Replacing the drop-in and reloading systemd are one repair. Record the
|
||||
# second half before changing the file so failure or interruption cannot
|
||||
# be forgotten when a retry sees only the trusted replacement on disk.
|
||||
if ! as_root /usr/bin/install -Dm0644 -o root -g root /dev/null "$reload_needed_marker"; then
|
||||
echo "Could not persist the pending systemd reload for the repaired supergfxd configuration" >&2
|
||||
exit 1
|
||||
fi
|
||||
systemd_reload_needed=true
|
||||
repair_unsafe_privileged_entry "$supergfxd_source" "$supergfxd_drop_in" 0644 delay-start.conf
|
||||
fi
|
||||
fi
|
||||
|
||||
if $systemd_reload_needed; then
|
||||
if ! as_root /usr/bin/systemctl daemon-reload; then
|
||||
echo "Could not reload systemd after repairing the supergfxd configuration; the migration will retry" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! as_root /usr/bin/rm -f -- "$reload_needed_marker"; then
|
||||
echo "Could not clear the pending systemd reload marker; the migration will retry" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
Reference in new issue
Block a user