Running Windows Processes Across Sessions
At work I encountered a situation where I had to start a Windows process in the active user session, from a system service running under the Local System account. Since it was hard to find a collected explanation on how to do this, I’ve decided to document it here.
The Problem
The product I am mainly working on is a Windows Desktop application, which consists of two components, installed as separate products: the application itself, and a system service used to provide updates to the application. The system service runs in the background, polling a server for new updates. When the user starts the application, if there is an available update, the system service will update the application and then launch the new version.
The updates of the application come in two variants:
- Simple file replacement update, to reduce update file size and update time.
- Full MSI installer upgrade, used when necessary.
In the first case the update is applied while the application is partly running and can launch the application once done.
The second case is trickier, as the application needs to be fully shut down before applying the MSI upgrade. The MSI does have logic for starting the application once the installation completes, but since it’s launched by the system service it runs in Session 0 and is unable to launch the application in the active user session.
The Solution
As with all tricky problems, there is an equally tricky solution - in this case using P/Invoke to leverage low-level Windows functionality is the right tool for the job.
I am going to be referencing various functions here, most of which have good documentation and examples at PInvoke.net.
The solution is to copy a token from a running process in the active session and use it to launch our own process in that same session.
Acquiring a user access token
The first thing we need to do is to find which session is the currently active one, for this we use WTSGetActiveConsoleSessionId
to find the session ID. Using this session ID, we look for a process running in that session. We then duplicate the user access token of this process.
var knownProcessId = 0;
var userAccessToken = IntPtr.Zero;
var processTokenHandle = IntPtr.Zero;
// We first need to find the active user session ID.
var activeConsoleSessionId = WTSGetActiveConsoleSessionId();
// Then we need to find the a process running in that session, explorer is typically safe.
foreach (var process in Process.GetProcessesByName("explorer"))
{
if ((uint)process.SessionId == activeConsoleSessionId)
{
knownProcessId = process.Id;
}
}
// Obtain a handle to the known process.
var processHandle = OpenProcess(ProcessAccessFlagsMaximumAllowed, false, knownProcessId);
// Obtain a handle to the access token of the known process.
if (!OpenProcessToken(processHandle, (uint)TokenAccessLevels.Duplicate, ref processTokenHandle))
{
Console.WriteLine($"Unable to obtain handle for known process, error code: {Marshal.GetLastWin32Error()}");
CloseHandle(processHandle);
return false;
}
// Copy the access token of the known executable process. The newly created token will be a primary token.
var securityAttributes = default(SECURITY_ATTRIBUTES);
securityAttributes.Length = Marshal.SizeOf(securityAttributes);
var duplicationResults = DuplicateTokenEx(processTokenHandle, (uint)TokenAccessLevels.MaximumAllowed, ref securityAttributes, SecurityIdentification, TokenTypeTokenPrimary, ref userAccessToken);
CloseHandle(processHandle);
CloseHandle(processTokenHandle);
if (!duplicationResults)
{
Console.WriteLine($"Unable to duplicate process token, error code: {Marshal.GetLastWin32Error()}");
return false;
}
Modifying the duplicated user access token
As icing on the cake, the application I need to start also requires UIAccess, so we need to set that flag as well on the token using SetTokenInformation
:
var tokenInformation = 1;
SetTokenInformation(userAccessToken, TokenUiAccess, ref tokenInformation, Marshal.SizeOf(tokenInformation));
var setTokenResult = Marshal.GetLastWin32Error();
if (setTokenResult != 0)
{
Console.WriteLine($"Unable to set UI access, error code: {setTokenResult}");
CloseHandle(userAccessToken);
return false;
}
Note that for this to work, the application you are duplicating a token from also needs to have UIAccess permissions or this won’t work (meaning that explorer
used above will not suffice. Luckily for me I had another process I could depend on for this.
Duplicating the user environment
Depending on what process you’re trying to start, you might want to ensure that you have access to the same environment variables as the process you’re copying a token from, e.g., if you’re using %APPDATA%.
if (!CreateEnvironmentBlock(out var environmentBlock, userAccessToken, true))
{
Console.WriteLine($"Unable to duplicate environment block, error code: {Marshal.GetLastWin32Error()}");
CloseHandle(userAccessToken);
return false;
}
Once you have launched your process, you also need to ensure you’re disposing of this duplicated environment:
DestroyEnvironmentBlock(environmentBlock);
Starting the application in the active session
Then we just need to set up the final parts and we can launch the process
var startupInfo = default(STARTUP_INFO);
startupInfo.cb = Marshal.SizeOf(startupInfo);
// This indicates that the process created can display a GUI on the desktop.
startupInfo.lpDesktop = @"winsta0\default";
const uint creationFlags = (uint)ProcessPriorityClass.NORMAL_PRIORITY_CLASS | (uint)ProcessCreationFlags.CREATE_NEW_CONSOLE;
// Create a new process in the current user's active session.
var result = CreateProcessWithTokenW(userAccessToken, LogonFlagWithProfile, null!, executablePathAndArgs, (CreationFlagNewConsole | CreationFlagUnicodeEnvironment), environmentBlock, workingDirectory, ref startupInfo, out _);
if (!result)
{
Console.WriteLine($"CreateProcessWithTokenW returned {Marshal.GetLastWin32Error()}");
}
CloseHandle(userAccessToken);
Thoughts
This was not the only explored solution to this problem and did not end up being the solution we went with - but I still thought it was interesting enough that I wanted to document it here.
While I often try to avoid resorting to P/Invokes, this solution is fairly robust (but make sure to destroy and release everything) - it makes no crazy assumptions and should work on any Windows version since Vista.
Final result
Here is the full snippet of code to be able to launch a process in another user session:
#region P/Invokes
[DllImport("kernel32.dll")]
public static extern uint WTSGetActiveConsoleSessionId();
[DllImport("kernel32.dll")]
public static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, int dwProcessId);
[DllImport("advapi32.dll", SetLastError = true), SuppressUnmanagedCodeSecurity]
public static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, ref IntPtr TokenHandle);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(IntPtr hHandle);
[DllImport("advapi32.dll", EntryPoint = "DuplicateTokenEx")]
public static extern bool DuplicateTokenEx(
IntPtr hExistingToken,
uint dwDesiredAccess,
ref SECURITY_ATTRIBUTES lpTokenAttributes,
int ImpersonationLevel,
int TokenType,
ref IntPtr phNewToken);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, ref int TokenInformation, int TokenInformationLength);
[DllImport("advapi32", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CreateProcessWithTokenW(
IntPtr hToken,
int dwLogonFlags,
string lpApplicationName,
string lpCommandLine,
int dwCreationFlags,
IntPtr lpEnvironment,
string lpCurrentDirectory,
[In] ref STARTUP_INFO lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[DllImport("userenv.dll", SetLastError = true)]
public static extern bool CreateEnvironmentBlock(out IntPtr lpEnvironment, IntPtr hToken, bool bInherit);
[DllImport("userenv.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);
#region Structs
[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public int Length;
public IntPtr lpSecurityDescriptor;
public bool bInheritHandle;
}
[StructLayout(LayoutKind.Sequential)]
public struct STARTUP_INFO
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
#endregion
#region Constants
private const uint ProcessAccessFlagsMaximumAllowed = 0x2000000;
private const int SecurityIdentification = 1;
private const int TokenTypeTokenPrimary = 1;
private const int TokenUiAccess = 26;
private const int LogonFlagWithProfile = 1;
private const int CreationFlagNewConsole = 0x00000010;
private const int CreationFlagUnicodeEnvironment = 0x00000400;
#endregion
#endregion
public static bool LaunchProcessInUserSession(string executablePathAndArgs, string workingDirectory, bool requireUiAccess)
{
var knownProcessId = 0;
var userAccessToken = IntPtr.Zero;
var processTokenHandle = IntPtr.Zero;
// We first need to find the active user session ID.
var activeConsoleSessionId = WTSGetActiveConsoleSessionId();
// Then we need to find the a process running in that session, explorer is typically safe.
foreach (var process in Process.GetProcessesByName("explorer"))
{
if ((uint)process.SessionId == activeConsoleSessionId)
{
knownProcessId = process.Id;
}
}
// Obtain a handle to the known process.
var processHandle = OpenProcess(ProcessAccessFlagsMaximumAllowed, false, knownProcessId);
// Obtain a handle to the access token of the known process.
if (!OpenProcessToken(processHandle, (uint)TokenAccessLevels.Duplicate, ref processTokenHandle))
{
Console.WriteLine($"Unable to obtain handle for known process, error code: {Marshal.GetLastWin32Error()}");
CloseHandle(processHandle);
return false;
}
// Copy the access token of the known executable process. The newly created token will be a primary token.
var securityAttributes = default(SECURITY_ATTRIBUTES);
securityAttributes.Length = Marshal.SizeOf(securityAttributes);
var duplicationResults = DuplicateTokenEx(processTokenHandle, (uint)TokenAccessLevels.MaximumAllowed, ref securityAttributes, SecurityIdentification, TokenTypeTokenPrimary, ref userAccessToken);
CloseHandle(processHandle);
CloseHandle(processTokenHandle);
if (!duplicationResults)
{
Console.WriteLine($"Unable to duplicate process token, error code: {Marshal.GetLastWin32Error()}");
return false;
}
var startupInfo = default(STARTUP_INFO);
startupInfo.cb = Marshal.SizeOf(startupInfo);
// This indicates that the process created can display a GUI on the desktop.
startupInfo.lpDesktop = @"winsta0\default";
if (requireUiAccess)
{
var tokenInformation = 1;
SetTokenInformation(userAccessToken, TokenUiAccess, ref tokenInformation, Marshal.SizeOf(tokenInformation));
var setTokenResult = Marshal.GetLastWin32Error();
if (setTokenResult != 0)
{
Console.WriteLine($"Unable to set UI access, error code: {setTokenResult}");
CloseHandle(userAccessToken);
return false;
}
}
if (!CreateEnvironmentBlock(out var environmentBlock, userAccessToken, true))
{
Console.WriteLine($"Unable to duplicate environment block, error code: {Marshal.GetLastWin32Error()}");
CloseHandle(userAccessToken);
return false;
}
var result = CreateProcessWithTokenW(userAccessToken, LogonFlagWithProfile, null!, executablePathAndArgs, (CreationFlagNewConsole | CreationFlagUnicodeEnvironment), environmentBlock, workingDirectory, ref startupInfo, out _);
if (!result)
{
Console.WriteLine($"CreateProcessWithTokenW returned {Marshal.GetLastWin32Error()}");
}
DestroyEnvironmentBlock(environmentBlock);
CloseHandle(userAccessToken);
return result;
}
1451 Words
2023-02-22