I've been banging my head against this for several hours, so I figured it's time to ask. I'll start with a high-level description of the situation. You can find the entire source code at https://github.com/Jay-Rad/InstaTech_Client. This question only pertains to the project in "/InstaTech_Service/".
Overview
The InstaTech client is a remote control app that uses websockets and makes an outbound connection to an ASP.NET server for relay with the viewer. I have different versions, but they all function roughly the same (the Electron version tries WebRTC first before using raw websockets). The viewer portion of the app is web-based, and a demo can be found here: https://instatech.org/Demo/Remote_Control
The WPF (C#) and Electron versions present a GUI with a random ID that they must provide to the person remoting into their computer (similar to TeamViewer). Once a session is started, they capture the screen in different ways. For C#, I'm using a pinvoke to BitBlt to copy the image to an in-memory graphic, which is then sent through the websocket. Subsequent screen captures are compared to the previous one to create a box that encompasses the changed pixels, then that cropped section is sent. Mouse and keyboard inputs are received by the client and executed via pinvoke to keybd_event and mouse_event. These are working great.
The service I created works in similar fashion, but here are the differences. The service itself runs in session 0 under System account. It connects to the server and listens on the websocket. When a connection is made and screen viewing is requested, it launches a separate interactive process in the user's session in WinSta0\Default. Once the new process's websocket is connected, the server begins relaying messages between it and the viewer instead of the service and the viewer.
Although the new process is launched interactively in the user's session, it's running under the System account. This is achieved by a pinvoke to CreateProcessAsUser and duplicating the winlogon.exe access token.
The Problem
This solution works fine if someone is already logged in, even if via RDP. However, if nobody is logged in or the computer gets locked, I can't interact with the logon screen. When doing the screen capture, I'm detecting if the capture fails, which would mean the WinSta0\Default desktop is no longer active. Since I'm using CreateProcessAsUser, I can switch desktops to the WinSta0\Winlogon just fine. I can still see it (even if no one is logged in), but it won't take any inputs. I understand that this is by design for security reasons. Well, strangely, some mouse movements "slip through" if I'm moving it around and cause the cursor to reposition, but the rest get sent to the Default desktop and execute once logged back in.
So the problem is that I can't get an account logged into the computer with this setup. If it matters, I don't care to interact with the Winlogon desktop if someone else is already logged in and locked the computer. I only want to be able to log in if no one else is using it, or it's my account that's logged in and at the lock screen.
Attempted Solutions
I'm assuming that there's no way to circumvent the inability to send simulated inputs to the Winlogon desktop. (Correction: That is, using mouse_event and keybd_event functions. I've seen other applications do it, like TeamViewer and Microsoft SCCM Remote Control. I'm not sure how they do it, though.) If it is somehow possible, I think that'd be the most direct route. But here are some things I've looked into that focus on getting a new logon session started.
Pinvoke to LsaLogonUser. I'm not sure if this would accomplish what I'm after, but I tried anyway. However, even though the call to LsaLogonUser reports success, the handle I'm getting from LsaRegisterLogonProcess (out to lsaHan) is 0. I'm not sure what I'm doing wrong. I'm not too familiar with Win32 calls and trying to pick it up as I go. Maybe the calling process doesn't have the necessary rights. I've tried calling this from the service in session 0 and from the process running in the interactive session. An example of what I'm doing is below.
Microsoft Terminal Services Active Client COM library. I haven't dug too deeply into this, but I wonder if it might be possible to use this to initiate an RDP logon session. Once an RDP logon session is made, spawn a new InstaTech process in that session and connect to it. I doubt this would work if the RDP connection is being attempted from the same computer, though.
Credential Provider. I came across credential providers while researching. I'm not sure if creating one would solve the problem, but it sounds like it'd be a terribly complicated undertaking.
Does anyone have any suggestions? Or am I missing something entirely?
If you'd like to recompile the service and test things, I created a temporary admin account on the server. Any computer with the service installed will show up there, and you can log in using this account. Please keep in mind that anyone reading this post will be able to access any computers running the service, so make sure it's in an isolated environment.
Username: admin
Password: plzh@lpm3purdyplz
The service is self-installing. Pass the -install switch to install, -uninstall to uninstall. The EXE is copied to %programdata%\InstaTech, and the service starts it from there.
Thank you!
Reference Code
public static void CreateNewSession()
{
var kli = new SECUR32.KERB_INTERACTIVE_LOGON()
{
MessageType = SECUR32.KERB_LOGON_SUBMIT_TYPE.KerbInteractiveLogon,
UserName = "[email protected]",
Password = "superencryptedstring"
};
IntPtr pluid;
IntPtr lsaHan;
ulong secMode;
uint authPackID;
IntPtr kerbLogInfo;
SECUR32.LSA_STRING logonProc = new SECUR32.LSA_STRING()
{
Buffer = Marshal.StringToHGlobalAuto("InstaLogon"),
Length = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("InstaLogon")),
MaximumLength = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("InstaLogon"))
};
SECUR32.LSA_STRING originName = new SECUR32.LSA_STRING()
{
Buffer = Marshal.StringToHGlobalAuto("InstaLogon"),
Length = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("InstaLogon")),
MaximumLength = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("InstaLogon"))
};
SECUR32.LSA_STRING authPackage = new SECUR32.LSA_STRING()
{
Buffer = Marshal.StringToHGlobalAuto("MICROSOFT_KERBEROS_NAME_A"),
Length = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("MICROSOFT_KERBEROS_NAME_A")),
MaximumLength = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("MICROSOFT_KERBEROS_NAME_A"))
};
IntPtr hLogonProc = Marshal.AllocHGlobal(Marshal.SizeOf(logonProc));
Marshal.StructureToPtr(logonProc, hLogonProc, false);
ADVAPI32.AllocateLocallyUniqueId(out pluid);
SECUR32.LsaRegisterLogonProcess(hLogonProc, out lsaHan, out secMode);
SECUR32.LsaLookupAuthenticationPackage(lsaHan, ref authPackage, out authPackID);
kerbLogInfo = Marshal.AllocHGlobal(Marshal.SizeOf(kli));
Marshal.StructureToPtr(kli, kerbLogInfo, false);
var ts = new SECUR32.TOKEN_SOURCE("Insta");
IntPtr profBuf;
uint profBufLen;
long logonID;
IntPtr logonToken;
SECUR32.QUOTA_LIMITS quotas;
SECUR32.WinStatusCodes subStatus;
SECUR32.LsaLogonUser(lsaHan, ref originName, SECUR32.SecurityLogonType.Interactive, authPackID, kerbLogInfo, (uint)Marshal.SizeOf(kerbLogInfo), IntPtr.Zero, ref ts, out profBuf, out profBufLen, out logonID, out logonToken, out quotas, out subStatus);
}
This is the method that the service in session 0 is using to launch another instance in the interactive session. I got most of this from this article: https://www.codeproject.com/kb/vista-security/subvertingvistauac.aspx. I only added the RDP session lookup.
public static bool OpenProcessAsSystem(string applicationName, out PROCESS_INFORMATION procInfo)
{
try
{
uint winlogonPid = 0;
IntPtr hUserTokenDup = IntPtr.Zero, hPToken = IntPtr.Zero, hProcess = IntPtr.Zero;
procInfo = new PROCESS_INFORMATION();
// Obtain session ID for active session.
uint dwSessionId = Kernel32.WTSGetActiveConsoleSessionId();
// Check for RDP session. If active, use that session ID instead.
var rdpSessionID = GetRDPSession();
if (rdpSessionID > 0)
{
dwSessionId = rdpSessionID;
}
// Obtain the process ID of the winlogon process that is running within the currently active session.
Process[] processes = Process.GetProcessesByName("winlogon");
foreach (Process p in processes)
{
if ((uint)p.SessionId == dwSessionId)
{
winlogonPid = (uint)p.Id;
}
}
// Obtain a handle to the winlogon process.
hProcess = Kernel32.OpenProcess(MAXIMUM_ALLOWED, false, winlogonPid);
// Obtain a handle to the access token of the winlogon process.
if (!OpenProcessToken(hProcess, TOKEN_DUPLICATE, ref hPToken))
{
Kernel32.CloseHandle(hProcess);
return false;
}
// Security attibute structure used in DuplicateTokenEx and CreateProcessAsUser.
SECURITY_ATTRIBUTES sa = new SECURITY_ATTRIBUTES();
sa.Length = Marshal.SizeOf(sa);
// Copy the access token of the winlogon process; the newly created token will be a primary token.
if (!DuplicateTokenEx(hPToken, MAXIMUM_ALLOWED, ref sa, (int)SECURITY_IMPERSONATION_LEVEL.SecurityIdentification, (int)TOKEN_TYPE.TokenPrimary, ref hUserTokenDup))
{
Kernel32.CloseHandle(hProcess);
Kernel32.CloseHandle(hPToken);
return false;
}
// By default, CreateProcessAsUser creates a process on a non-interactive window station, meaning
// the window station has a desktop that is invisible and the process is incapable of receiving
// user input. To remedy this we set the lpDesktop parameter to indicate we want to enable user
// interaction with the new process.
STARTUPINFO si = new STARTUPINFO();
si.cb = (int)Marshal.SizeOf(si);
si.lpDesktop = @"winsta0\default"; // interactive window station parameter; basically this indicates that the process created can display a GUI on the desktop
// flags that specify the priority and creation method of the process
uint dwCreationFlags = NORMAL_PRIORITY_CLASS | CREATE_NEW_CONSOLE;
// create a new process in the current user's logon session
bool result = CreateProcessAsUser(hUserTokenDup, // client's access token
null, // file to execute
applicationName, // command line
ref sa, // pointer to process SECURITY_ATTRIBUTES
ref sa, // pointer to thread SECURITY_ATTRIBUTES
false, // handles are not inheritable
dwCreationFlags, // creation flags
IntPtr.Zero, // pointer to new environment block
null, // name of current directory
ref si, // pointer to STARTUPINFO structure
out procInfo // receives information about new process
);
// invalidate the handles
Kernel32.CloseHandle(hProcess);
Kernel32.CloseHandle(hPToken);
Kernel32.CloseHandle(hUserTokenDup);
return result;
}
catch
{
procInfo = new PROCESS_INFORMATION() { };
return false;
}
}
public static uint GetRDPSession()
{
IntPtr ppSessionInfo = IntPtr.Zero;
Int32 count = 0;
Int32 retval = WTSAPI32.WTSEnumerateSessions(WTSAPI32.WTS_CURRENT_SERVER_HANDLE, 0, 1, ref ppSessionInfo, ref count);
Int32 dataSize = Marshal.SizeOf(typeof(WTSAPI32.WTS_SESSION_INFO));
var sessList = new List<WTSAPI32.WTS_SESSION_INFO>();
Int64 current = (int)ppSessionInfo;
if (retval != 0)
{
for (int i = 0; i < count; i++)
{
WTSAPI32.WTS_SESSION_INFO sessInf = (WTSAPI32.WTS_SESSION_INFO)Marshal.PtrToStructure((System.IntPtr)current, typeof(WTSAPI32.WTS_SESSION_INFO));
current += dataSize;
sessList.Add(sessInf);
}
}
uint retVal = 0;
var rdpSession = sessList.Find(ses => ses.pWinStationName.ToLower().Contains("rdp") && ses.State == 0);
if (sessList.Exists(ses => ses.pWinStationName.ToLower().Contains("rdp") && ses.State == 0))
{
retVal = (uint)rdpSession.SessionID;
}
return retVal;
}