Tech/Engineering

Handling User-Level Tasks in Windows with Golang: Impersonation, Goroutines, and a Task Processor

Dharmik Patel, Sr. Staff Software Engineer

Imagine a scenario where your application that’s running with elevated privileges like Local System, needs to perform actions on behalf of specific users in a Windows environment. This could range from backing up EFS-encrypted files to accessing user-specific registry settings or network resources. The challenge lies in the inherent restrictions of Windows, which prevent processes running under Local System from directly accessing user-level resources. In this post, we'll explore how we tackled this problem using Golang, focusing on Windows impersonation, goroutine management, and the creation of a versatile "User Task Processor."

Problem Analysis

We aimed to develop a robust solution that could handle various user-level operations in a Windows environment. The core issue stemmed from the fact that processes running under the Local System account lack the necessary privileges to access user-specific resources.

One obvious way to solve this problem was to spawn a new process that runs with the user’s privilege, enabling it to access user-level resources. However, this approach had several drawbacks:

  • Multi-user backup requirement: In Windows machines, our backup process needs to perform backups of multiple user accounts on a single device. The backup process should be able to back up user data (except in special cases where user privilege is required) even when the user is not logged into the machine. Additionally, running a user-mode process imposes certain restrictions on accessing system resources, which can further complicate the backup process.

  • Increased system footprint: Launching an additional process increases the memory and CPU usage, which we wanted to avoid for a lightweight client.

  • Inter-process communication (IPC) complexity: Securely transferring sensitive data between processes is challenging.

  • Data transfer overhead: Since filesystem data must be securely passed through the chosen IPC method, this could introduce performance bottlenecks.

The Solution

A more efficient solution was to leverage the same Local System-level process and dynamically acquire the necessary user privileges through impersonation. By carefully analyzing the Windows API for impersonation, we identified that certain process threads could be seamlessly impersonated with user-level privileges. This approach allowed our backup process to securely access user-level resources without spawning additional processes, minimizing complexity and resource usage.

user level task 1

Implementation Using Windows API

We chose this approach and utilized the Windows system API ImpersonateLoggedOnUser to temporarily acquire the user’s privilege within our existing backup process. This allowed us to securely execute user-level tasks without requiring a separate process or complex IPC mechanisms.

user level task 2

Challenges with Golang’s Goroutines

While implementing this solution in Golang, we encountered an additional challenge related to goroutines and OS thread management:

  • Windows APIs impose a restriction that once an OS thread is impersonated, it must not be changed during execution.
  • However, Golang’s scheduler dynamically assigns goroutines to available OS threads, meaning an impersonated thread might be reassigned for another task, or a non-impersonated thread might be used for a user-level task—both leading to unintended behavior. Reference doc

Solving the Goroutine Scheduling Issue

To ensure that an impersonated thread is used consistently for user-level tasks, we leveraged Golang’s runtime.LockOSThread function. This function locks a goroutine to its current OS thread, preventing Golang’s scheduler from moving it to another thread during execution. This ensured that:

  1. The impersonated thread remained bound to the goroutine performing the decryption.
  2. Unintended thread switching did not break the expected behavior.
user level task 3


However, locking OS threads introduced another challenge:

  • Since our client operates with many parallel goroutines for efficient backups, locking too many threads could negatively impact system resources.

Optimizing Performance with a User-Level Thread Pool

To balance performance and resource utilization, we implemented a thread pool of impersonated threads. This thread pool is a part of our User Task Processor module which accepts tasks, executes them in user context, and shares the response to the caller.

Architecture Overview

The User Task Processor operates on the following architectural principles:

  1. Impersonated thread pool: A fixed-size thread pool, where each thread is impersonated with a specific user's privileges.
  2. Task Queue (request channel): A queue (or channel in Golang) that receives tasks from producer components.
  3. Task Format: Tasks are structured with a task_type identifier and task_data.
  4. Response Channel: Each task includes a response channel for the producer to receive the result.
  5. Task Factory/Dispatcher: Based on the task_type, a factory or dispatcher allocates the task to the appropriate handler.
user level task 4


Workflow:

  1. The producer creates a task with a task_type, task_data and response_channel.
  2. The producer sends the task to the Task Queue.
  3. An impersonated thread retrieves the task.
  4. The Task Factory identifies the handler based on the task_type.
  5. The handler executes the task, using the impersonated thread's assumed user privileges. 
  6. The result is sent back to the producer through the Response Channel.

Conclusion

By carefully leveraging Windows APIs and Golang’s threading model, we successfully enabled seamless execution of user-specific tasks while maintaining a minimal system footprint. This solution ensures:

  • Secure handling of data without additional processes or IPC overhead.

  • Efficient use of OS threads through controlled impersonation and locking.

  • Optimized performance with a dynamically managed thread pool.

This approach showcases how a deep understanding of both Windows internals and Golang’s runtime behavior can lead to an elegant and efficient solution for complex system-level problems.