Skip to content

Instantly share code, notes, and snippets.

@robertsinfosec
Last active September 7, 2025 18:42
Show Gist options
  • Select an option

  • Save robertsinfosec/cde20dd093955f0b38ea07baff3a7f16 to your computer and use it in GitHub Desktop.

Select an option

Save robertsinfosec/cde20dd093955f0b38ea07baff3a7f16 to your computer and use it in GitHub Desktop.
When you want to carefully and safely move the `UID`/`GID` of a user in Linux. This needs to be run as a `root`, directly. You can't log in as `sysadmin`, then do `sudo -s` and run this against `sysadmin`, it will give an error.
#!/usr/bin/env bash
# move-user-uid.sh — Safely move a user to a new UID/GID and fix file ownerships.
# Usage: ./move-user-uid.sh <username> <new-uid> <new-gid> [--force]
# Example: ./move-user-uid.sh operations 2000 2000 --force
set -euo pipefail
# ── Colors ─────────────────────────────────────────────────────────────────────
Black='\033[0;30m'
DarkGray='\033[1;30m'
Red='\033[0;31m'
LightRed='\033[1;31m'
Green='\033[0;32m'
LightGreen='\033[1;32m'
Brown='\033[0;33m'
Yellow='\033[1;33m'
Blue='\033[0;34m'
LightBlue='\033[1;34m'
Purple='\033[0;35m'
LightPurple='\033[1;35m'
Cyan='\033[0;36m'
LightCyan='\033[1;36m'
LightGray='\033[0;37m'
White='\033[1;37m'
NC='\033[0m' # No Color
# ── Status printer ─────────────────────────────────────────────────────────────
function setStatus(){
description=$1
severity=${2:-"*"}
case "$severity" in
s) echo -e "[${LightGreen}+${NC}] ${LightGreen}${description}${NC}" ;;
f) echo -e "[${Red}-${NC}] ${LightRed}${description}${NC}" ;;
q) echo -e "[${LightPurple}?${NC}] ${LightPurple}${description}${NC}" ;;
*) echo -e "[${LightCyan}*${NC}] ${LightCyan}${description}${NC}" ;;
esac
}
# ── Help / Args ────────────────────────────────────────────────────────────────
if [[ $# -lt 3 ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then
cat <<'EOF'
Usage: move-user-uid.sh <username> <new-uid> <new-gid> [--force]
Safely change an existing user's UID/GID and recursively fix file ownership
across local filesystems. Refuses to proceed if the user has an active session,
unless --force is specified.
Examples:
sudo ./move-user-uid.sh operations 2000 2000
sudo ./move-user-uid.sh operations 2000 2000 --force
EOF
exit 0
fi
USER_NAME="$1"
NEW_UID="$2"
NEW_GID="$3"
FORCE=0; [[ "${4:-}" == "--force" ]] && FORCE=1
# ── Pre-flight validation ─────────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
setStatus "Run as root." f; exit 1
fi
if ! id "$USER_NAME" >/dev/null 2>&1; then
setStatus "User '$USER_NAME' not found." f; exit 1
fi
# numeric + sensible ranges (allow >= 1000 by default)
if ! [[ "$NEW_UID" =~ ^[0-9]+$ ]] || ! [[ "$NEW_GID" =~ ^[0-9]+$ ]]; then
setStatus "UID/GID must be numeric. Got UID='$NEW_UID' GID='$NEW_GID'." f; exit 1
fi
if [[ "$NEW_UID" -eq 0 || "$NEW_GID" -eq 0 ]]; then
setStatus "Refusing to set UID/GID 0." f; exit 1
fi
if [[ "$NEW_UID" -lt 1000 || "$NEW_GID" -lt 1000 ]]; then
setStatus "Warning: UID/GID < 1000 is typically reserved. Continue only if intentional." q
fi
OLD_UID="$(id -u "$USER_NAME")"
OLD_GID="$(id -g "$USER_NAME")"
PRIMARY_GROUP="$(id -gn "$USER_NAME")"
setStatus "Current state: user='$USER_NAME' uid=$OLD_UID gid=$OLD_GID (group '$PRIMARY_GROUP')." s
setStatus "Target state: user='$USER_NAME' uid=$NEW_UID gid=$NEW_GID." s
if [[ "$OLD_UID" -eq "$NEW_UID" && "$OLD_GID" -eq "$NEW_GID" ]]; then
setStatus "No change needed; already at requested UID/GID." s
exit 0
fi
# collision checks
if getent passwd "$NEW_UID" >/dev/null && [[ "$OLD_UID" -ne "$NEW_UID" ]]; then
owner="$(getent passwd "$NEW_UID" | cut -d: -f1)"
setStatus "Refusing: UID $NEW_UID already used by user '$owner'." f; exit 1
fi
if getent group "$NEW_GID" >/dev/null && [[ "$OLD_GID" -ne "$NEW_GID" && "$PRIMARY_GROUP" != "$(getent group "$NEW_GID" | cut -d: -f1)" ]]; then
gname="$(getent group "$NEW_GID" | cut -d: -f1)"
setStatus "Refusing: GID $NEW_GID already used by group '$gname'." f; exit 1
fi
# active session detection
ACTIVE_USERS="$( (who 2>/dev/null || true) | awk '{print $1}' | sort -u )"
if command -v loginctl >/dev/null 2>&1; then
ACTIVE_USERS="$ACTIVE_USERS"$'\n'"$(loginctl list-sessions --no-legend 2>/dev/null | awk '{print $3}' | sort -u)"
fi
if echo "$ACTIVE_USERS" | grep -qx "$USER_NAME"; then
if [[ $FORCE -ne 1 ]]; then
setStatus "Refusing: '$USER_NAME' has an active session. Re-run with --force or log them out first." f
exit 1
else
setStatus "Proceeding despite active session for '$USER_NAME' (because --force)." q
fi
fi
# ── Plan summary ──────────────────────────────────────────────────────────────
cat <<EOF
$(echo -e "${LightCyan}Plan:${NC}")
• Change primary group '$PRIMARY_GROUP' to GID $NEW_GID (create or modify as needed)
• Change user '$USER_NAME' to UID $NEW_UID and primary GID $NEW_GID
• Recursively reassign ownership from ${OLD_UID}:${OLD_GID} to ${NEW_UID}:${NEW_GID} on local filesystems
• Fix common special paths (mailbox, crontab)
EOF
# ── Quiesce sessions (if any) ────────────────────────────────────────────────
setStatus "Terminating processes for '$USER_NAME' (uid=$OLD_UID) to avoid file-busy errors..." "*"
loginctl terminate-user "$USER_NAME" >/dev/null 2>&1 || true
pkill -u "$OLD_UID" >/dev/null 2>&1 || true
setStatus "Process termination attempted (ignore if none were running)." s
# ── Ensure /etc/group target exists/updated ───────────────────────────────────
if ! getent group "$PRIMARY_GROUP" >/dev/null; then
setStatus "Primary group '$PRIMARY_GROUP' not found; creating with GID $NEW_GID..." "*"
groupadd -g "$NEW_GID" "$PRIMARY_GROUP"
setStatus "Group created." s
else
setStatus "Setting group '$PRIMARY_GROUP' to GID $NEW_GID..." "*"
groupmod -g "$NEW_GID" "$PRIMARY_GROUP"
setStatus "Group GID updated." s
fi
# ── Change user UID/GID ──────────────────────────────────────────────────────
setStatus "Changing '$USER_NAME' to UID $NEW_UID and GID $NEW_GID..." "*"
usermod -u "$NEW_UID" -g "$NEW_GID" -o "$USER_NAME"
setStatus "User UID/GID updated." s
# ── Ownership relabel across local filesystems ───────────────────────────────
# Gather local mount points (single pass, unique)
mapfile -t MOUNTS < <(awk '$3 ~ /^(ext[234]|xfs|btrfs|zfs)$/ {print $2}' /proc/mounts | sort -u)
setStatus "Relabeling files from ${OLD_UID}:${OLD_GID} to ${NEW_UID}:${NEW_GID} on local filesystems..." "*"
for mp in "${MOUNTS[@]}"; do
# Only proceed if mountpoint exists and is a directory
[[ -d "$mp" ]] || continue
# Reassign ownership; -h handles symlinks, -xdev stays within that filesystem
find "$mp" -xdev -uid "$OLD_UID" -exec chown -h "$NEW_UID":"$NEW_GID" {} + 2>/dev/null || true
find "$mp" -xdev -gid "$OLD_GID" -exec chgrp -h "$NEW_GID" {} + 2>/dev/null || true
done
setStatus "Relabel complete (best-effort; skipped non-local/virtual filesystems)." s
# ── Common special cases ─────────────────────────────────────────────────────
[[ -e "/var/mail/$USER_NAME" ]] && chown "$NEW_UID:$NEW_GID" "/var/mail/$USER_NAME" || true
[[ -e "/var/spool/cron/crontabs/$USER_NAME" ]] && chown "$NEW_UID:$NEW_GID" "/var/spool/cron/crontabs/$USER_NAME" || true
# ── Report final state ───────────────────────────────────────────────────────
NEW_STATE_UID="$(id -u "$USER_NAME")"
NEW_STATE_GID="$(id -g "$USER_NAME")"
if [[ "$NEW_STATE_UID" -eq "$NEW_UID" && "$NEW_STATE_GID" -eq "$NEW_GID" ]]; then
setStatus "Success: '$USER_NAME' is now uid=$NEW_STATE_UID gid=$NEW_STATE_GID." s
setStatus "Log out and back in to refresh any cached credentials/sessions." q
exit 0
else
setStatus "Error: post-check shows uid=$NEW_STATE_UID gid=$NEW_STATE_GID (expected $NEW_UID:$NEW_GID)." f
exit 1
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment