Launching a GUI application from a Windows Service using C#

It isn’t usually possible to launch a GUI (Graphical User Interface) application from a Windows Service. There are good reasons for this; aside from the security considerations, being interrupted while doing something important by a badly behaving background application would not be fun!

However, there are some limited use cases for starting processes that feature a GUI from a Windows Service, such as for specific kinds of software updates and monitoring systems. If you have already looked at alternatives and believe that launching a GUI application from a Windows Service is most appropriate for your scenario, read on and I’ll show you how to achieve this.

A lot of the information available online regarding this topic tends to be a bit on the vague side. By contrast, I will provide you with a full working implementation and a link to the source code which you can clone/download and run to get started.

Solution background

In older versions of the Windows operating system, such as Windows XP, GUI applications could be started from a Windows Service by enabling the Allow service to interact with desktop option for the service.

However, this all changed in 2007 when Microsoft released their latest version of Windows.

Session 0

Remember the all-but-forgotten Windows Vista? When it was first introduced, Microsoft introduced Session 0 Isolation whereby all Windows Services run in a special non-interactive system session referred to as ‘Session 0’, whereas all user sessions run in ‘Session 1’ or above.

With Session 0 Isolation, the ‘Allow service to interact with desktop’ option takes on a new meaning. It now means that a GUI application can be started by a Windows Service, but its user interface will appear within a virtual desktop in Session 0 which is hidden from the user. As you can imagine, this isn’t going to be of much benefit to very many applications.

Security

Security is a major reason why it isn’t normally possible to launch a GUI application from a Windows Service. If a Windows Service is running as a highly privileged user, such as the Local System account and a user-interactive application is launched under the context of this account by the service, the end-user could potentially use that application to exploit the system by carrying out actions that they would normally not have permission to do.

However, there is a lesser-known, safer way of launching a GUI application under the context of the currently logged-in user from a Windows Service that works by obtaining the ‘primary token’ of the user. The aforementioned method will launch the application with the standard permissions that the user has, without allowing any administrative elevation. Most applications do not need to run as administrator, so this approach will work just fine for the majority of scenarios and it is better for security as the application will fail to launch if administrative privileges are required.

The key thing to bear in mind is that the Windows Service in question must be running as the Local System account. As mentioned above, even though Local System is a highly privileged account, with the approach that I am documenting it is only possible to launch an application with the same privileges as the currently logged-in user. Regardless of whether or not the current user is an administrator, trying to launch the application with administrative rights using this approach will not work, and that is a good thing.

With all that said and without further, let’s take a look at the solution implementation.

Solution implementation

The solution involves invoking WIN32 methods that are imported into a .NET application so that we can call them using C# code. Ultimately, the native method that is called to launch the GUI application is the CreateProcessAsUser method.

It can be challenging to piece together all of the things you need to call the CreateProcessAsUser method correctly. Therefore, I have distilled all of the code that is needed to make the process creation happen correctly into a class that is documented below for your reference and ease of use.

using JC.Samples.WindowsServiceGuiLauncher.Services.Interfaces;
using System.Diagnostics;
using System.Runtime.InteropServices;

namespace JC.Samples.WindowsServiceGuiLauncher.Services; /// <summary> /// Provides services for launching new processes. /// </summary> public class ProcessServices : IProcessServices {     #region WIN32     #region Constants     private const uint  CREATE_UNICODE_ENVIRONMENT = 0x00000400;     private const int   GENERIC_ALL_ACCESS         = 0x10000000;     private const int   STARTF_FORCEONFEEDBACK     = 0x00000040;     private const int   STARTF_USESHOWWINDOW       = 0x00000001;     private const short SW_SHOW                    = 5;     private const uint  TOKEN_ASSIGN_PRIMARY       = 0x0001;     private const uint  TOKEN_DUPLICATE            = 0x0002;     private const uint  TOKEN_QUERY                = 0x0008;     #endregion     #region Structs     [StructLayout(LayoutKind.Sequential)]     private struct PROCESS_INFORMATION     {         public IntPtr hProcess;         public IntPtr hThread;         public uint   dwProcessId;         public uint   dwThreadId;     }    [StructLayout(LayoutKind.Sequential)]     private struct SECURITY_ATTRIBUTES     {         public uint   nLength;         public IntPtr lpSecurityDescriptor;         public bool   bInheritHandle;     }    [StructLayout(LayoutKind.Sequential)]     private struct STARTUPINFO     {         public uint   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;     }     #endregion     #region Enums     private enum SECURITY_IMPERSONATION_LEVEL     {         SecurityAnonymous      = 0,         SecurityIdentification = 1,         SecurityImpersonation  = 2,         SecurityDelegation     = 3     }     private enum TOKEN_TYPE     {         TokenPrimary       = 1,         TokenImpersonation = 2     }     #endregion     #region Imports     [DllImport("kernel32.dll", SetLastError = true)]     private static extern bool CloseHandle(        IntPtr hObject);     [DllImport("userenv.dll", SetLastError = true)]     private static extern bool CreateEnvironmentBlock(         ref IntPtr lpEnvironment,         IntPtr     hToken,         bool       bInherit);     [DllImport("advapi32.dll", SetLastError = true)]     private static extern bool CreateProcessAsUser(         IntPtr                  hToken,         string?                 lpApplicationName,         string?                 lpCommandLine,         ref SECURITY_ATTRIBUTES lpProcessAttributes,         ref SECURITY_ATTRIBUTES lpThreadAttributes,         bool                    bInheritHandles,         uint                    dwCreationFlags,         IntPtr                  lpEnvironment,         string?                 lpCurrentDirectory,         ref STARTUPINFO         lpStartupInfo,         out PROCESS_INFORMATION lpProcessInformation);     [DllImport("userenv.dll", SetLastError = true)]     private static extern bool DestroyEnvironmentBlock(        IntPtr lpEnvironment);     [DllImport("advapi32.dll", EntryPoint = "DuplicateTokenEx", SetLastError = true)]     private static extern bool DuplicateTokenEx(         IntPtr                       hExistingToken,         uint                         dwDesiredAccess,         ref SECURITY_ATTRIBUTES      lpTokenAttributes,         SECURITY_IMPERSONATION_LEVEL impersonationLevel,         TOKEN_TYPE                   tokenType,         ref IntPtr                   phNewToken);     [DllImport("advapi32.dll", SetLastError = true)]     private static extern bool OpenProcessToken(         IntPtr     processHandle,         uint       desiredAccess,         ref IntPtr tokenHandle);     #endregion     #endregion     #region Readonlys     private readonly ILogger<ProcessServices>? _logger;     #endregion     #region Constructor     /// <summary>     /// Default Constructor.     /// </summary>     public ProcessServices()     {     }     /// <summary>     /// Constructor.     /// </summary>     /// <param name="logger"><see cref="ILogger"/></param>     public ProcessServices(ILogger<ProcessServices> logger)     {         _logger = logger;     }     #endregion     #region Methods     #region Public     /// <summary>     /// Starts a process as the currently logged in user.     /// </summary>     /// <param name="processCommandLine">The full process command-line</param>     /// <param name="processWorkingDirectory">The process working directory (optional)</param>     /// <param name="userProcess">The user process to get the Primary Token from (optional)</param>     /// <returns>True if the process started successfully, otherwise false</returns>     public bool StartProcessAsCurrentUser(         string   processCommandLine,          string?  processWorkingDirectory = null,          Process? userProcess = null)     {         bool success = false;         if (userProcess == null)         {             // If a specific user process hasn't been specified, use the explorer process.            Process[] processes = Process.GetProcessesByName("explorer");             if (processes.Any())             {                userProcess = processes[0];             }         }         if (userProcess != null)         {            IntPtr token = GetPrimaryToken(userProcess);             if (token != IntPtr.Zero)             {                IntPtr block = IntPtr.Zero;                 try                 {                     block   = GetEnvironmentBlock(token);                    success = LaunchProcess(processCommandLine, processWorkingDirectory, token, block);                 }                 finally                 {                     if (block != IntPtr.Zero)                     {                         DestroyEnvironmentBlock(block);                     }                     CloseHandle(token);                 }             }         }         return success;     }     #endregion     #region Private     /// <summary>     /// Gets the Environment Block based on the specified token.     /// </summary>     /// <param name="token">The token pointer</param>     /// <returns>The Environment Block pointer</returns>     private IntPtr GetEnvironmentBlock(IntPtr token)     {        IntPtr block  = IntPtr.Zero;         bool   result = CreateEnvironmentBlock(ref block, tokenfalse);         if (!result)         {             _logger?.LogError("CreateEnvironmentBlock Error: {0}", Marshal.GetLastWin32Error());         }         return block;     }     /// <summary>     /// Gets the Primary Token for the specified process.     /// </summary>     /// <param name="process">The process to get the token for</param>     /// <returns>The token pointer</returns>     private IntPtr GetPrimaryToken(Process process)     {        IntPtr primaryToken = IntPtr.Zero;         // Get the impersonation token.        IntPtr token      = IntPtr.Zero;         bool   openResult = OpenProcessToken(process.Handle, TOKEN_DUPLICATE, ref token);         if (openResult)         {             try             {                 var securityAttributes     = new SECURITY_ATTRIBUTES();                securityAttributes.nLength = (uint)Marshal.SizeOf(securityAttributes);                 // Convert the impersonation token into a Primary token.                openResult = DuplicateTokenEx(                    token,                     TOKEN_ASSIGN_PRIMARY | TOKEN_DUPLICATE | TOKEN_QUERY,                     ref securityAttributes,                     SECURITY_IMPERSONATION_LEVEL.SecurityIdentification,                     TOKEN_TYPE.TokenPrimary,                     ref primaryToken);             }             finally             {                 CloseHandle(token);             }             if (!openResult)             {                 _logger?.LogError("DuplicateTokenEx Error: {0}", Marshal.GetLastWin32Error());             }         }         else         {             _logger?.LogError("OpenProcessToken Error: {0}", Marshal.GetLastWin32Error());         }         return primaryToken;     }     /// <summary>     /// Launches the process as the user indicated by the token and Environment Block.     /// </summary>     /// <param name="commandLine">The full process command-line</param>     /// <param name="workingDirectory">The process working directory</param>     /// <param name="token">The token pointer</param>     /// <param name="environmentBlock">The Environment Block pointer</param>     /// <returns>True if the process was launched successfully, otherwise false</returns>     private bool LaunchProcess(         string commandLine,          string? workingDirectory,          IntPtr token,          IntPtr environmentBlock)     {         var startupInfo = new STARTUPINFO();         startupInfo.cb  = (uint)Marshal.SizeOf(startupInfo);         // If 'lpDesktop' is NULL, the new process will inherit the desktop and window station of its parent process.         // If it is an empty string, the process does not inherit the desktop and window station of its parent process;          // instead, the system determines if a new desktop and window station need to be created.         // If the impersonated user already has a desktop, the system uses the existing desktop.         startupInfo.lpDesktop   = @"WinSta0\Default"// Modify as needed.         startupInfo.dwFlags     = STARTF_USESHOWWINDOW | STARTF_FORCEONFEEDBACK;        startupInfo.wShowWindow = SW_SHOW;         var processSecurityAttributes     = new SECURITY_ATTRIBUTES();         processSecurityAttributes.nLength = (uint)Marshal.SizeOf(processSecurityAttributes);         var threadSecurityAttributes     = new SECURITY_ATTRIBUTES();         threadSecurityAttributes.nLength = (uint)Marshal.SizeOf(threadSecurityAttributes);         bool result = CreateProcessAsUser(            token,             null,            commandLine,             ref processSecurityAttributes,             ref threadSecurityAttributes,             false,             CREATE_UNICODE_ENVIRONMENT,            environmentBlock,            workingDirectory,             ref startupInfo,             out _);         if (!result)         {             _logger?.LogError("CreateProcessAsUser Error: {0}", Marshal.GetLastWin32Error());         }         return result;     }     #endregion     #endregion }

Note that you should update the namespace and using statements in the code sample according to your project.

In the following sub-sections, I will provide a breakdown of each part of the above class. I highly recommend that you check out the associated GitHub repository so that you can see how the ProcessServices class is used along with the public StartProcessAsCurrentUser method to launch a process from a .NET Core Worker Service which is configured to run as a Windows Service.

Interface

The ProcessServices class implements an interface that is documented below for reference.

using System.Diagnostics;
 
namespace JC.Samples.WindowsServiceGuiLauncher.Services.Interfaces;
 
/// <summary>
/// Process Services interface.
/// </summary>
public interface IProcessServices
{
    #region Methods
 
    bool StartProcessAsCurrentUser(
        string   processCommandLine, 
        string?  processWorkingDirectory = null, 
        Process? userProcess = null);
 
    #endregion
}

The interface is used in the sample project on GitHub for dependency injection. If you don’t need the interface in your application, feel free to remove it and the associated using statement.

WIN32

The solution relies heavily on native WIN32 methods. The ‘WIN32’ region at the top of the ProcessServices class contains the definition of all the constants, structs, and method imports that are needed to facilitate calls into the unmanaged code.

PInvoke.net is a useful resource for establishing the correct signatures and types to use when you want to invoke methods within unmanaged libraries from managed code within your .NET application.

The Microsoft Learn website is also an invaluable resource for cross-referencing purposes to help ensure that method signatures and types are matching up with the original C++ code. For example, the PROCESS_INFORMATION page contains Syntax, Members and Remarks sections that helpfully document each aspect of the PROCESS_INFORMATION struct.

I find it helpful to keep the WIN32-related code as faithful as possible to the original C++ implementation, using the same capitalisation for constants/structs/enums and the same variable naming conventions. This makes it easier to cross-reference with online documentation and hints that it is unmanaged code we are calling into.

The DllImport attribute along with the extern keyword is used to indicate that the method signatures within the ‘Import’ sub-region are implemented within external DLLs (Dynamic Link Libraries).

Initialisation

The ProcessServices class features a default constructor and a second constructor that accepts a typed ILogger instance as a parameter, which naturally is used for logging events. If you don’t need logging, you can use the default parameterless constructor to create a ProcessServices object instance.

Public methods

The main public interface method is named StartProcessAsCurrentUser and accepts the process command line as a required parameter. This is intended to represent the full command line of the process to start and should therefore include the process name along with any arguments/switches that are to be passed to the process.

The StartProcessAsCurrentUser method also accepts a second, optional parameter that allows the working directory for the process to be set. This can be quite useful as the default working directory is typically the Windows System folder (C:\Windows\System32) and depending on your application this could affect its operation significantly.

The third parameter of the StartProcessAsCurrentUser method is also optional and allows a Process object from which to obtain the user’s primary token to be passed in. If unspecified this will default to the ‘explorer’ process, as this process is normally running all of the time on a user’s system and will typically be running under the context of the current user.

At a high level, the StartProcessAsCurrentUser method obtains a pointer to the user’s primary token by calling the private GetPrimaryToken method. The private GetEnvironmentBlock method is subsequently called to get a pointer to the user’s environment block (environment variables). The token and block pointers are then passed to the third private method named LaunchProcess to start the process as the currently logged-in user.

The code ensures that resources relating to the primary token and environment block are cleaned up properly by calling the unmanaged DestroyEnvironmentBlock and CloseHandle methods respectively and doing so within a try-finally block. This is very important and is something that is frequently missed out when developers are attempting to smash unfamiliar code together.

Private methods

The first private method that is called by the public StartProcessAsCurrentUser method is the GetPrimaryToken method which gets a pointer to a primary token based on the specified process.

The GetPrimaryToken method calls the unmanaged OpenProcessToken method to open the access token for the specified process. The unmanaged DuplicateTokenEx method is then called to create a duplicate primary token with the specified attributes. Again, the code is careful to clean things up by closing the handle to the access token. The static Marshal.GetLastWin32Error method is called when logging errors and it retrieves the error code that was set by the last unmanaged method call. This can be very useful for troubleshooting purposes.

The GetEnvironmentBlock method calls the unmanaged CreateEnvironmentBlock method to get environment variables for a user based on the specified token. The Marshal.GetLastWin32Error method is called if the result returned by the CreateEnvironmentBlock method is false for error logging purposes.

The LaunchProcess method features several parameters; the command line, working directory, primary token, and environment block to use when creating the process. Within the method, the STARTUPINFO struct is first created and its fields are set with the appropriate values. ‘WinSta0\Default’ is a special value that is used to represent the default desktop for the currently logged-in user. Aside from this, notice the flags that are used to show the application window when it is launched, you can adjust these flags according to your requirements.

After creating process and thread security attributes using the SECURITY_ATTRIBUTES struct, the CreateProcessAsUser method is called. The CreateProcessAsUser parameter values that have been specified in the code sample should be suitable for the majority of applications, however, you are free to review the relevant Microsoft Learn documentation further and adjust things according to your specific needs.

As per the CreateEnvironmentBlock method, the CreateProcessAsUser method returns a boolean value indicating if the method call was successful. If the process was not created successfully the result will be false and the Marshal.GetLastWin32Error method will be called to determine the error code to log.

Testing the implementation

To test the implementation properly you’ll need to install your application as a Windows Service that runs as the Local System account and check that the service can launch a GUI application successfully.

If you are unsure of how to install a Windows Service, check out the Windows Service installation section within my How to create and install a .NET Core Windows Service article. You’ll need to adjust the sc command to set the ‘obj’ argument to ‘LocalSystem’, as shown below.

sc create WindowsServiceGuiLauncher binPath= "<path_to_publish_directory>\JC.Samples.WindowsServiceGuiLauncher.exe" DisplayName= "Windows Service GUI Launcher" obj= LocalSystem start= auto

Note that you must change the name of the service and path to match your application.

Regarding code, you will need to create an instance of the ProcessServices class and call the StartProcessAsCurrentUser method with the appropriate parameter values within your Windows Service. A basic example of this is shown below for your reference.

var processServices = new ProcessServices();
processServices.StartProcessAsCurrentUser("notepad");

I’m assuming that you already know the basics of how to create a Windows Service project and that you can determine the most appropriate place to call the StartProcessAsCurrentUser method within your application code. If you are new to Windows Services you can check out my How to create and install a .NET Core Windows Service article for information on how to create a .NET Core Worker Service and wire it up to run as a Windows Service.

If you have placed the above code somewhere in your program such that it will execute after the Windows Service starts running (as a test) you should see an instance of the Notepad program created and visible within your desktop environment!

Before wrapping things up, I would like to recommend again that you check out my GitHub repository which uses dependency injection to create an instance of the ProcessServices class and automatically injects an ILogger instance as part of a Worker Service project that is configured to run as a Windows Service.

Summary

In this article, I have documented a class that provides a public method you can call to start a GUI application process from a Windows Service that is running as the Local System account.

I started by providing some background on ‘Session 0 Isolation’ and security considerations.

I then dived into the implementation of the ProcessServices class which contains the public StartProcessAsCurrentUser method that you can call from within your Windows Service codebase to successfully launch a GUI application in the context of the currently logged-in user.

I finished by explaining the basic steps you need to carry out to test the solution for your application. I highly recommend that you clone or download the associated GitHub repository which includes all of the code from this article and a working .NET Core Worker Service application that you can build and run to see the functionality in action.


I hope you enjoyed this post! Comments are always welcome and I respond to all questions.

If you like my content and it helped you out, please check out the button below 🙂

Comments

Joao

Hi Jonathan,
Thank you so much to share you work.
I’ve made few tests on it, and, it works well for the admin user. However when I try to do it with another user it’s impossible to open an application.
Do you have an idea ?
Thank you so much
Best Regards
Joao Fernandes

September 4, 2023

Jonathan Crozier

Hi Joao, no problem and thank you!

Can you tell me some more details about the issue you’re having? Is it only a problem on a specific device? Does it work OK on your machine?

If you’ve kept the logging code in place, it would also be useful to know what the error code is.

September 4, 2023

joao Fernandes

Hi
Thanks for your feedback,
I’m targeting a new installed device, It’s a Dell tablet. And according to the logs everything is running however I don’t see the notepad open for all users.
I’ve configured 2 users, the first one is the admin created as the main user during the Windows installation. The second one is a new one created after.
For the first one everything works well and when I start the service, notepad is open and the “StartProcessAsCurrentUser” returns a true.
With the second one when I start the service, notepad is not opened and “StartProcessAsCurrentUser” returns a true. I’ve tested to create a folder and file and the creation of these 2 items are working well, however open applications that’s not working. At the end I’ve created a third user with the same administration rights however without password (just for test propose) and I got the same behavior, no error, no exception however no application open.
Thank you
Best regards
Joao Fernandes

September 6, 2023

Jonathan Crozier

Hi again, are you able to confirm that the Windows Service is running as the ‘Local System’ user? What operating system are you trying this on? Is there anything else unusual about the Dell tablet configuration e.g. is Windows Explorer not running under the secondary user accounts or perhaps the User Account Control settings are not standard?

September 10, 2023

Balázs

Hi Jonathan,

Thanks for your post. I would like to use your code in my project after small modifications. Am I allowed to do that? Since at the bottom of this page you wrote “Copyright 2023, Jonathan Crozier.
All rights reserved.”. Also I could not find license text in your linked GitHub repository. That’s why I’m asking.

Thanks,
Balázs

October 18, 2023

Jonathan Crozier

Hi Balázs,

That’s absolutely fine, I have added a license file to the repo.

October 18, 2023

fobrien

Hi Jonathan,

First of all, congratulations on this brilliant script and for sharing with everybody !
I am looking for a way to display a popup message generated in C# on the Windows logon screen using your method but when I try to open the template downloaded from GitHub, Visual Studio returns an error message stating that the project could not be opened (I tried both 2017 and 2022 versions).
What am I doing wrong ?
Thanks in advance.

April 15, 2024

Jonathan Crozier

Thank you very much Frederic!

It’s possible that you do not have the ‘.NET desktop development’ workload components for Visual Studio installed. You can check this using the Visual Studio Installer interface.

Are you able to share the exact error message?

April 15, 2024

fobrien

Hi Jonathan,
Thank you for your reply.
After having checked, the “.NET Desktop Development” feature is installed.
Here is the error message I am having in the output window when loading the project :
error : Project file is incomplete. Expected imports are missing.
Thanks.
Fred

April 16, 2024

Jonathan Crozier

Hi Fred,

From looking online, it appears this is most likely an issue with either the global.json file or the .NET Core version.

Here is a link to a StackOverflow question that might help.

https://stackoverflow.com/questions/49432666/project-file-is-incomplete-expected-imports-are-missing

April 16, 2024