diff --git a/ee/currentprocess/currentprocess.go b/ee/currentprocess/currentprocess.go new file mode 100644 index 000000000..578617402 --- /dev/null +++ b/ee/currentprocess/currentprocess.go @@ -0,0 +1,2 @@ +// Package currentprocess provides information on the running process. +package currentprocess diff --git a/ee/currentprocess/currentprocess_posix.go b/ee/currentprocess/currentprocess_posix.go new file mode 100644 index 000000000..3f9929361 --- /dev/null +++ b/ee/currentprocess/currentprocess_posix.go @@ -0,0 +1,19 @@ +//go:build !windows + +package currentprocess + +import ( + "os" + "strconv" +) + +// Returns whether the current process is root. +func IsElevated() (bool, error) { + return os.Geteuid() == 0, nil +} + +// Returns the current process's numerical user id. All platforms +// return strings because of Windows. +func Uid() (string, error) { + return strconv.Itoa(os.Getuid()), nil +} diff --git a/ee/currentprocess/currentprocess_test.go b/ee/currentprocess/currentprocess_test.go new file mode 100644 index 000000000..762eac5a0 --- /dev/null +++ b/ee/currentprocess/currentprocess_test.go @@ -0,0 +1,38 @@ +package currentprocess + +import ( + "os/user" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestIsElevated(t *testing.T) { + t.Parallel() + + // Cannot consistently assert outcome, but it should never error. + _, err := IsElevated() + require.NoError(t, err) +} + +func TestUid(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + expected := currentUser.Uid + if runtime.GOOS == "windows" { + expected = currentUser.Username + } + + uid, err := Uid() + require.NoError(t, err) + require.Equal(t, expected, uid) +} diff --git a/pkg/launcher/paths_windows.go b/ee/currentprocess/currentprocess_windows.go similarity index 67% rename from pkg/launcher/paths_windows.go rename to ee/currentprocess/currentprocess_windows.go index 9ca446757..1b6b35e60 100644 --- a/pkg/launcher/paths_windows.go +++ b/ee/currentprocess/currentprocess_windows.go @@ -1,9 +1,10 @@ //go:build windows -package launcher +package currentprocess import ( "fmt" + "os/user" "unsafe" "golang.org/x/sys/windows" @@ -12,7 +13,7 @@ import ( // Detects UAC elevation or when running as LocalSystem. // Impl is copied from windows.Token.IsElevated, but exposes the error // on a failure to check. -func runningElevated() (bool, error) { +func IsElevated() (bool, error) { var elevation uint32 var outLen uint32 if err := windows.GetTokenInformation( @@ -27,3 +28,13 @@ func runningElevated() (bool, error) { return outLen == uint32(unsafe.Sizeof(elevation)) && elevation != 0, nil } + +// Returns the current process's fully-qualified owner, DOMAIN\User. +func Uid() (string, error) { + currentUser, err := user.Current() + if err != nil { + return "", fmt.Errorf("getting current user: %w", err) + } + + return currentUser.Username, nil +} diff --git a/ee/debug/checkups/flare-environment-platform-specifics_windows.go b/ee/debug/checkups/flare-environment-platform-specifics_windows.go index a69e786ae..7fb5936ce 100644 --- a/ee/debug/checkups/flare-environment-platform-specifics_windows.go +++ b/ee/debug/checkups/flare-environment-platform-specifics_windows.go @@ -2,8 +2,19 @@ package checkups -import "golang.org/x/sys/windows" +import ( + "fmt" + + "github.com/kolide/launcher/v2/ee/currentprocess" +) func flareEnvironmentPlatformSpecifics(flareEnv map[string]any) { - flareEnv["invoked_with_elevated_permissions"] = windows.GetCurrentProcessToken().IsElevated() + elevated, err := currentprocess.IsElevated() + + if err != nil { + flareEnv["invoked_with_elevated_permissions"] = "unknown" + flareEnv["invoked_with_elevated_permissions_err"] = fmt.Sprintf("failed to check if elevated: %v", err) + } else { + flareEnv["invoked_with_elevated_permissions"] = elevated + } } diff --git a/ee/desktop/runner/runner.go b/ee/desktop/runner/runner.go index 882192973..38ac3bcd1 100644 --- a/ee/desktop/runner/runner.go +++ b/ee/desktop/runner/runner.go @@ -30,6 +30,7 @@ import ( "github.com/kolide/launcher/v2/ee/agent/types" "github.com/kolide/launcher/v2/ee/allowedcmd" "github.com/kolide/launcher/v2/ee/consoleuser" + "github.com/kolide/launcher/v2/ee/currentprocess" runnerserver "github.com/kolide/launcher/v2/ee/desktop/runner/server" "github.com/kolide/launcher/v2/ee/desktop/user/client" "github.com/kolide/launcher/v2/ee/desktop/user/menu" @@ -112,6 +113,11 @@ func (e NoExplorerProcessError) Error() string { return fmt.Sprintf("no explorer process found for uid: %s", e.uid) } +func (e NoExplorerProcessError) Is(target error) bool { + _, ok := target.(NoExplorerProcessError) + return ok +} + // DesktopUsersProcessesRunner creates a launcher desktop process each time it detects // a new console (GUI) user. If the current console user's desktop process dies, it // will create a new one. @@ -146,8 +152,12 @@ type DesktopUsersProcessesRunner struct { knapsack types.Knapsack // runnerServer is a local server that desktop processes call to monitor parent runnerServer *runnerserver.RunnerServer - // osVersion is the version of the OS cached in new + // osVersion is the version of the OS, cached in new osVersion string + // currentUid is the process owning uid, cached in new + currentUid string + // elevated is whether the process runs elevated, cached in new + elevated bool // cachedMenuData is the cached label values of the currently displayed menu data, used for detecting changes cachedMenuData *menuItemCache } @@ -190,6 +200,25 @@ func New(k types.Knapsack, messenger runnerserver.Messenger, opts ...desktopUser runner.slogger = k.Slogger().With("component", "desktop_runner") + elevated, err := currentprocess.IsElevated() + if err != nil { + runner.slogger.Log(context.TODO(), slog.LevelWarn, + "failed to check if process is elevated, will assume process is privileged", + "err", err, + ) + elevated = true // fail loud: maybe succeed rather than never try + } + runner.elevated = elevated + + runner.currentUid, err = currentprocess.Uid() + if err != nil { + runner.slogger.Log(context.TODO(), slog.LevelWarn, + "failed to get current process uid, will behave like system process if privileged", + "elevated", elevated, + "err", err, + ) + } + for _, opt := range opts { opt(runner) } @@ -843,6 +872,14 @@ func (r *DesktopUsersProcessesRunner) writeDefaultMenuTemplateFile() { } } +// isCurrentUser reports whether uid is the user this process runs as. Windows UIDs +// are fully qualified names, hence the case insensitive check. +func (r *DesktopUsersProcessesRunner) isCurrentUser(uid string) bool { + return strings.EqualFold(uid, r.currentUid) +} + +// Fans out over all users on this device. We can spawn a launcher desktop subprocess +// for console users if required and we have permission. At minimum, we check for ourselves. func (r *DesktopUsersProcessesRunner) runConsoleUserDesktop() error { if r.knapsack.InModernStandby() { r.slogger.Log(context.TODO(), slog.LevelDebug, @@ -857,9 +894,18 @@ func (r *DesktopUsersProcessesRunner) runConsoleUserDesktop() error { ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) defer cancel() - consoleUsers, err := consoleuser.CurrentUids(ctx) - if err != nil { - return fmt.Errorf("getting console users: %w", err) + var consoleUsers []string + + // Querying console users on Windows requires privilege, other platforms + // use session information to establish process configuration even when ran + // unprivileged + if runtime.GOOS == "windows" && !r.elevated { + consoleUsers = []string{r.currentUid} + } else { + var err error + if consoleUsers, err = consoleuser.CurrentUids(ctx); err != nil { + return fmt.Errorf("getting console users: %w", err) + } } for _, uid := range consoleUsers { @@ -867,6 +913,11 @@ func (r *DesktopUsersProcessesRunner) runConsoleUserDesktop() error { continue } + // Only a privileged process can start a process for somebody else. + if !r.isCurrentUser(uid) && !r.elevated { + continue + } + // Check to see if necessary dependencies are running on macOS before we spawn the desktop process. // This will block for up to 30 seconds, at which point we proceed with trying to spawn anyway. r.waitForReadyToSpawnDesktopState(ctx, uid) diff --git a/ee/desktop/runner/runner_darwin.go b/ee/desktop/runner/runner_darwin.go index 0f8112a50..23e80e435 100644 --- a/ee/desktop/runner/runner_darwin.go +++ b/ee/desktop/runner/runner_darwin.go @@ -16,15 +16,16 @@ import ( "golang.org/x/sys/unix" ) -// For notifications to work, we must run in the user context with launchctl asuser. +// Starts the provided cmd and returns any errors from spawning the process. If the uid differs from the user +// running the current process, runAsUser uses `launchctl runas` to start cmd in the user's context (required for +// notifications to work). Otherwise it runs cmd directly. func (r *DesktopUsersProcessesRunner) runAsUser(ctx context.Context, uid string, cmd *allowedcmd.TracedCmd) error { _, span := observability.StartSpan(ctx, "uid", uid) defer span.End() - // Ensure that we handle a non-root current user appropriately - currentUser, err := user.Current() - if err != nil { - return fmt.Errorf("getting current user: %w", err) + // we do not need to launch into our own user + if r.isCurrentUser(uid) { + return cmd.Start() } runningUser, err := user.LookupId(uid) @@ -42,17 +43,6 @@ func (r *DesktopUsersProcessesRunner) runAsUser(ctx context.Context, uid string, updatedCmdArgs := append([]string{"/bin/launchctl", "asuser", uid, "sudo", "--preserve-env", "-u", runningUser.Username}, cmd.Args...) cmd.Args = updatedCmdArgs - // current user not root - if currentUser.Uid != "0" { - // if the user is running for itself, just run without setting credentials - if currentUser.Uid == runningUser.Uid { - return cmd.Start() - } - - // if the user is running for another user, we have an error because we can't set credentials - return fmt.Errorf("current user %s is not root and can't start process for other user %s", currentUser.Uid, uid) - } - // the remaining code in this function is not covered by unit test since it requires root privileges // We may be able to run passwordless sudo in GitHub actions, could possibly exec the tests as sudo. // But we may not have a console user? diff --git a/ee/desktop/runner/runner_linux.go b/ee/desktop/runner/runner_linux.go index 44381c279..f210eef6b 100644 --- a/ee/desktop/runner/runner_linux.go +++ b/ee/desktop/runner/runner_linux.go @@ -30,29 +30,30 @@ const ( // Display takes the format host:displaynumber.screen var displayRegex = regexp.MustCompile(`^[a-z]*:\d+.?\d*$`) +// Starts the provided cmd and returns any errors from spawning the process. If the uid differs from the user +// running the current process, runAsUser launches the cmd in their user and group namespaces. Otherwise, +// it directly launches the cmd. +// +// In all cases, the current process queries logind for the uid's session and sets the environment variables +// present on cmd. func (r *DesktopUsersProcessesRunner) runAsUser(ctx context.Context, uid string, cmd *allowedcmd.TracedCmd) error { ctx, span := observability.StartSpan(ctx, "uid", uid) defer span.End() - currentUser, err := user.Current() - if err != nil { - return fmt.Errorf("getting current user: %w", err) - } - runningUser, err := user.LookupId(uid) if err != nil || runningUser == nil { return fmt.Errorf("looking up user with uid %s: %w", uid, err) } - // current user not root - if currentUser.Uid != "0" { - // if the user is running for itself, just run without setting credentials - if currentUser.Uid == runningUser.Uid { - return cmd.Start() - } + // Set any necessary environment variables on the command (like DISPLAY) + envVars := r.userEnvVars(ctx, uid, runningUser.Username) + for k, v := range envVars { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) + } - // if the user is running for another user, we have an error because we can't set credentials - return fmt.Errorf("current user %s is not root and can't start process for other user %s", currentUser.Uid, uid) + // no special credentials need to be set + if r.isCurrentUser(uid) { + return cmd.Start() } // the remaining code in this function is not covered by unit test since it requires root privileges @@ -76,12 +77,6 @@ func (r *DesktopUsersProcessesRunner) runAsUser(ctx context.Context, uid string, }, } - // Set any necessary environment variables on the command (like DISPLAY) - envVars := r.userEnvVars(ctx, uid, runningUser.Username) - for k, v := range envVars { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) - } - return cmd.Start() } diff --git a/ee/desktop/runner/runner_test.go b/ee/desktop/runner/runner_test.go index 71bd125c5..ba8bf9741 100644 --- a/ee/desktop/runner/runner_test.go +++ b/ee/desktop/runner/runner_test.go @@ -3,6 +3,7 @@ package runner import ( "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -21,6 +22,7 @@ import ( "github.com/kolide/launcher/v2/ee/agent/types" "github.com/kolide/launcher/v2/ee/agent/types/mocks" "github.com/kolide/launcher/v2/ee/consoleuser" + "github.com/kolide/launcher/v2/ee/currentprocess" "github.com/kolide/launcher/v2/ee/desktop/user/notify" "github.com/kolide/launcher/v2/ee/presencedetection" "github.com/kolide/launcher/v2/pkg/backoff" @@ -632,3 +634,23 @@ func Test_Ping_writesLocalizationFile(t *testing.T) { require.NoError(t, json.Unmarshal(contents, &got)) require.Equal(t, expected.Locale, got.Locale) } + +func TestIsCurrentUser(t *testing.T) { + t.Parallel() + + currentUid, err := currentprocess.Uid() + require.NoError(t, err) + + r := &DesktopUsersProcessesRunner{currentUid: currentUid} + require.True(t, r.isCurrentUser(currentUid)) + require.False(t, r.isCurrentUser("not-a-real-uid")) +} + +func TestNoExplorerProcessError_Is(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("wrapped: %w", NoExplorerProcessError{uid: `DOMAIN\someuser`}) + require.ErrorIs(t, err, NoExplorerProcessError{}) + + require.NotErrorIs(t, errors.New("unrelated"), NoExplorerProcessError{}) +} diff --git a/ee/desktop/runner/runner_windows.go b/ee/desktop/runner/runner_windows.go index 4325e10db..f61458663 100644 --- a/ee/desktop/runner/runner_windows.go +++ b/ee/desktop/runner/runner_windows.go @@ -15,10 +15,22 @@ import ( "github.com/kolide/systray" ) +// Starts the provided cmd and returns any errors from spawning the process. If the uid differs from the user +// running the current process, runAsUser binds the cmd to the secure token of the target uid's explorer +// process. Otherwise it runs cmd directly. func (r *DesktopUsersProcessesRunner) runAsUser(ctx context.Context, uid string, cmd *allowedcmd.TracedCmd) error { ctx, span := observability.StartSpan(ctx, "uid", uid) defer span.End() + // ERROR_PRIVILEGE_NOT_HELD returns from cmd.Start() if the current process or token handle hit the + // wrong row of a non-trivial privilege matrix: see CreateProcessAsUser docs. + // Trying to start is safer when this process is also the target user. + // + // NB: breaks non-graphical session spawning like tasks or over ssh. + if r.isCurrentUser(uid) { + return cmd.Start() + } + explorerProc, err := consoleuser.ExplorerProcess(ctx, uid) if err != nil { return fmt.Errorf("getting user explorer process: %w", err) diff --git a/ee/watchdog/controller_windows.go b/ee/watchdog/controller_windows.go index e68ff746b..33b0d53a5 100644 --- a/ee/watchdog/controller_windows.go +++ b/ee/watchdog/controller_windows.go @@ -18,11 +18,11 @@ import ( "github.com/kolide/launcher/v2/ee/agent/flags/keys" agentsqlite "github.com/kolide/launcher/v2/ee/agent/storage/sqlite" "github.com/kolide/launcher/v2/ee/agent/types" + "github.com/kolide/launcher/v2/ee/currentprocess" "github.com/kolide/launcher/v2/ee/log" "github.com/kolide/launcher/v2/ee/observability" "github.com/kolide/launcher/v2/ee/powereventwatcher" "github.com/kolide/launcher/v2/pkg/launcher" - "golang.org/x/sys/windows" ) const ( @@ -251,8 +251,11 @@ func (wc *WatchdogController) shouldManageWatchdog() bool { return false } - // we also don't alter watchdog installation if we're running without elevated permissions - if !windows.GetCurrentProcessToken().IsElevated() { + if elevated, err := currentprocess.IsElevated(); err != nil { + // historical behavior treats failure to check as not elevated + wc.slogger.Log(context.TODO(), slog.LevelError, "failed to check if process is elevated", "err", err) + return false + } else if !elevated { return false } diff --git a/pkg/launcher/paths.go b/pkg/launcher/paths.go index bb1f7e63a..e942f7522 100644 --- a/pkg/launcher/paths.go +++ b/pkg/launcher/paths.go @@ -9,6 +9,7 @@ import ( "runtime" "strings" + "github.com/kolide/launcher/v2/ee/currentprocess" "github.com/kolide/launcher/v2/pkg/log/multislogger" ) @@ -110,8 +111,8 @@ func DetermineRootDirectoryOverride(logger *slog.Logger, optsRootDirectory, koli return optsRootDirectory } - elevated, err := runningElevated() //nolint:staticcheck // Linux/MacOS unreachable, always errors - if err != nil { //nolint:staticcheck + elevated, err := currentprocess.IsElevated() + if err != nil { logger.Log(context.TODO(), slog.LevelWarn, "failed to check if process is elevated, assuming privileged", "err", err, diff --git a/pkg/launcher/paths_other.go b/pkg/launcher/paths_other.go deleted file mode 100644 index 652777727..000000000 --- a/pkg/launcher/paths_other.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build !windows - -package launcher - -import "errors" - -// Unreachable on non-Windows, included for compilation. -func runningElevated() (bool, error) { - return false, errors.New("OS does not support elevation check") -} diff --git a/pkg/log/multislogger/syslogger_windows.go b/pkg/log/multislogger/syslogger_windows.go index ac5d76b94..df89578b7 100644 --- a/pkg/log/multislogger/syslogger_windows.go +++ b/pkg/log/multislogger/syslogger_windows.go @@ -8,18 +8,22 @@ import ( "log/slog" "os" + "github.com/kolide/launcher/v2/ee/currentprocess" "github.com/kolide/launcher/v2/pkg/log/eventlog" - "golang.org/x/sys/windows" ) const serviceName = "launcher" func SystemSlogger() (*MultiSlogger, io.Closer, error) { - if !windows.GetCurrentProcessToken().IsElevated() { + // On a failure to determine elevation, assume we are unprivileged rather than + // attempting an eventlog write we may not be permitted to make. + elevated, err := currentprocess.IsElevated() + if err != nil || !elevated { syslogger := defaultSystemSlogger() syslogger.Log(context.TODO(), slog.LevelInfo, "launcher running on windows without elevated permissions, using default stdout instead of eventlog", + "err", err, ) return syslogger, io.NopCloser(nil), nil