Windows Vista 中服务和应用程序之间的用户级别交互






4.97/5 (30投票s)
本文档介绍了在Windows Vista中处理服务和应用程序的问题。提供了C++和C#的解决方案。本文档可能对那些从事组织Windows Vista上服务和应用程序之间交互任务的人员有用。
引言
本文档介绍了在Windows Vista中处理服务和应用程序的问题。特别是,我们将考虑如何从服务启动一个交互式的用户级别应用程序,以及如何组织服务和应用程序之间的数据交换。提供了C++和C#的解决方案。本文档可能对那些使用托管和原生代码来组织Windows Vista上服务和应用程序之间交互任务的人员有用。
Windows Vista、服务和桌面
在Vista之前,Windows系列操作系统中的服务和用户应用程序可以共享session 0。可以直接从服务打开当前用户的桌面窗口,也可以通过窗口消息在服务和应用程序之间交换数据。但是,当出现利用服务打开的窗口来访问服务本身的整个一类攻击时,这成了一个严重的安全性问题。直到Vista才出现了对抗此类攻击的机制。
在Windows Vista中,所有用户登录和注销都在session 0之外的其他session中进行。服务打开用户桌面的窗口的可能性受到很大限制,如果您尝试从服务启动一个应用程序,它将在session 0中启动。相应地,如果该应用程序是交互式的,您必须切换到session 0的桌面。使用窗口消息进行数据交换已变得相当困难。
这种安全策略是可以理解的。但是,如果您仍然需要从服务在用户桌面上启动一个交互式应用程序怎么办?本文描述了该问题的可能解决方案之一。此外,我们将考虑几种组织服务和应用程序之间数据交换的方法。
从服务启动交互式应用程序
只要服务和当前用户的桌面存在于不同的session中,服务就必须“伪装”成该用户才能启动交互式应用程序。为此,我们需要知道相应的登录名和密码,或者拥有LocalSystem账户。第二种方式更常见,所以我们将对此进行考虑。
因此,我们使用LocalSystem账户创建服务。首先,我们应该获取当前用户的令牌。为此,我们
- 获取所有终端会话的列表;
- 选择活动会话;
- 获取登录到活动会话的用户的令牌;
- 复制获得的令牌。
C++代码
您可以在下面看到相应的C++代码
PHANDLE GetCurrentUserToken()
{
PHANDLE currentToken = 0;
PHANDLE primaryToken = 0;
int dwSessionId = 0;
PHANDLE hUserToken = 0;
PHANDLE hTokenDup = 0;
PWTS_SESSION_INFO pSessionInfo = 0;
DWORD dwCount = 0;
// Get the list of all terminal sessions
WTSEnumerateSessions(WTS_CURRENT_SERVER_HANDLE, 0, 1,
&pSessionInfo, &dwCount);
int dataSize = sizeof(WTS_SESSION_INFO);
// look over obtained list in search of the active session
for (DWORD i = 0; i < dwCount; ++i)
{
WTS_SESSION_INFO si = pSessionInfo[i];
if (WTSActive == si.State)
{
// If the current session is active – store its ID
dwSessionId = si.SessionId;
break;
}
}
WTSFreeMemory(pSessionInfo);
// Get token of the logged in user by the active session ID
BOOL bRet = WTSQueryUserToken(dwSessionId, currentToken);
if (bRet == false)
{
return 0;
}
bRet = DuplicateTokenEx(currentToken,
TOKEN_ASSIGN_PRIMARY | TOKEN_ALL_ACCESS,
0, SecurityImpersonation, TokenPrimary, primaryToken);
if (bRet == false)
{
return 0;
}
return primaryToken;
}
值得注意的是,您可以使用WTSGetActiveConsoleSessionId()
函数来代替遍历整个列表。该函数返回活动会话的ID。但是,当我将其用于实际任务时,我发现该函数并不总是有效,而遍历所有会话的方式始终返回正确的结果。如果当前会话没有登录用户,则函数WTSQueryUserToken()
将返回FALSE,错误代码为ERROR_NO_TOKEN
。自然,在这种情况下您不能使用下面的代码。在我们获得令牌后,就可以代表当前用户启动一个应用程序。请确保应用程序的权限与当前用户账户的权限相匹配,而不是LocalSystem账户的权限。代码如下。
BOOL Run(const std::string& processPath, const std::string& arguments)
{
// Get token of the current user
PHANDLE primaryToken = GetCurrentUserToken();
if (primaryToken == 0)
{
return FALSE;
}
STARTUPINFO StartupInfo;
PROCESS_INFORMATION processInfo;
StartupInfo.cb = sizeof(STARTUPINFO);
SECURITY_ATTRIBUTES Security1;
SECURITY_ATTRIBUTES Security2;
std::string command = "\"" +
processPath + "\"";
if (arguments.length() != 0)
{
command += " " + arguments;
}
void* lpEnvironment = NULL;
// Get all necessary environment variables of logged in user
// to pass them to the process
BOOL resultEnv = CreateEnvironmentBlock(&lpEnvironment,
primaryToken, FALSE);
if (resultEnv == 0)
{
long nError = GetLastError();
}
// Start the process on behalf of the current user
BOOL result = CreateProcessAsUser(primaryToken, 0,
(LPSTR)(command.c_str()), &Security1,
&Security2, FALSE, CREATE_NO_WINDOW | NORMAL_PRIORITY_CLASS |
CREATE_UNICODE_ENVIRONMENT, lpEnvironment, 0,
&StartupInfo, &processInfo);
DestroyEnvironmentBlock(lpEnvironment);
CloseHandle(primaryToken);
return result;
}
如果开发的软件仅在Windows Vista及更高版本的操作系统中使用,那么您可以使用CreateProcessWithTokenW()
函数代替CreateProcessAsUser()
。例如,可以这样调用它
BOOL result = CreateProcessWithTokenW(primaryToken, LOGON_WITH_PROFILE,
0, (LPSTR)(command.c_str()),
CREATE_NO_WINDOW | NORMAL_PRIORITY_CLASS |
CREATE_UNICODE_ENVIRONMENT, lpEnvironment, 0,
&StartupInfo, &processInfo);
C#代码
让我们在C#中实现相同的功能。我们创建一个ProcessStarter
类,该类将在后续的示例中使用。ProcessStarter
的C++和C#完整实现包含在附件中,这里我只描述两个主要方法。
public static IntPtr GetCurrentUserToken()
{
IntPtr currentToken = IntPtr.Zero;
IntPtr primaryToken = IntPtr.Zero;
IntPtr WTS_CURRENT_SERVER_HANDLE = IntPtr.Zero;
int dwSessionId = 0;
IntPtr hUserToken = IntPtr.Zero;
IntPtr hTokenDup = IntPtr.Zero;
IntPtr pSessionInfo = IntPtr.Zero;
int dwCount = 0;
WTSEnumerateSessions(WTS_CURRENT_SERVER_HANDLE, 0, 1,
ref pSessionInfo, ref dwCount);
Int32 dataSize = Marshal.SizeOf(typeof(WTS_SESSION_INFO));
Int32 current = (int)pSessionInfo;
for (int i = 0; i < dwCount; i++)
{
WTS_SESSION_INFO si = (WTS_SESSION_INFO)Marshal.PtrToStructure(
(System.IntPtr)current, typeof(WTS_SESSION_INFO));
if (WTS_CONNECTSTATE_CLASS.WTSActive == si.State)
{
dwSessionId = si.SessionID;
break;
}
current += dataSize;
}
WTSFreeMemory(pSessionInfo);
bool bRet = WTSQueryUserToken(dwSessionId, out currentToken);
if (bRet == false)
{
return IntPtr.Zero;
}
bRet = DuplicateTokenEx(currentToken,
TOKEN_ASSIGN_PRIMARY | TOKEN_ALL_ACCESS,
IntPtr.Zero, SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation,
TOKEN_TYPE.TokenPrimary, out primaryToken);
if (bRet == false)
{
return IntPtr.Zero;
}
return primaryToken;
}
public void Run()
{
IntPtr primaryToken = GetCurrentUserToken();
if (primaryToken == IntPtr.Zero)
{
return;
}
STARTUPINFO StartupInfo = new STARTUPINFO();
processInfo_ = new PROCESS_INFORMATION();
StartupInfo.cb = Marshal.SizeOf(StartupInfo);
SECURITY_ATTRIBUTES Security1 = new SECURITY_ATTRIBUTES();
SECURITY_ATTRIBUTES Security2 = new SECURITY_ATTRIBUTES();
string command = "\"" + processPath_ + "\"";
if ((arguments_ != null) && (arguments_.Length != 0))
{
command += " " + arguments_;
}
IntPtr lpEnvironment = IntPtr.Zero;
bool resultEnv = CreateEnvironmentBlock(out lpEnvironment,
primaryToken, false);
if (resultEnv != true)
{
int nError = GetLastError();
}
CreateProcessAsUser(primaryToken, null, command, ref Security1,
ref Security2, false,
CREATE_NO_WINDOW | NORMAL_PRIORITY_CLASS |
CREATE_UNICODE_ENVIRONMENT,
lpEnvironment, null, ref StartupInfo,
out processInfo_);
DestroyEnvironmentBlock(lpEnvironment);
CloseHandle(primaryToken);
}
此外,还有一篇关于使用LocalSystem账户权限从服务启动用户级别应用程序的优秀文章,地址是:在Vista中以Local System账户运行应用程序而不出现UAC提示。
服务与应用程序之间的数据交换
现在只剩下解决服务和应用程序之间数据交换的问题了。您可以使用多种选项:套接字、命名内存映射文件、RPC和COM。在这里,我们将考虑三种最简单的方式:文本文件、事件(用于C#)和命名管道(用于C++)。
文本文件
最简单的解决方案之一是使用文本文件。当我们谈论基于C#的开发时,最自然的选择是使用XML文件。
例如,我们必须将一些数据字符串从用户级别的应用程序传递到服务。首先,我们必须决定中介文件应该在哪里创建。该位置必须是应用程序和服务都可以访问的。
如果应用程序是以当前登录用户的权限启动的,那么使用该用户的“我的文档”文件夹将是一个很好的解决方案。在这种情况下,双方都不会有访问问题(因为LocalSystem服务几乎有权访问所有地方)。
因此,让我们在当前用户的“我的文档”文件夹中创建一个XML文件“sample.xml”
using System.Xml;
XmlWriterSettings xmlWriterSettings = new XmlWriterSettings();
// provide the XML declaration
xmlWriterSettings.OmitXmlDeclaration = false;
// write attributes on the new line
xmlWriterSettings.NewLineOnAttributes = true;
// indent elements
xmlWriterSettings.Indent = true;
// get “My Documents” folder path
String myDocumentsPath =
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
String sampleXmlFilePath = Path.Combine(myDocumentsPath,”sample.xml”);
// create the XML file “sample.xml”
sampleXmlWriter = XmlWriter.Create(sampleXmlFilePath, xmlWriterSettings);
现在,我们将创建“SampleElement
”元素,一些有用的数据将传递给它
sampleXmlWriter.WriteStartElement("SampleElement");
sampleXmlWriter.WriteElementString("Data", "Hello");
完成文件创建
sampleXmlWriter.WriteEndElement();
sampleXmlWriter.Flush();
sampleXmlWriter.Close();
现在,服务必须打开该文件。为了访问它,服务必须首先获取当前用户的“我的文档”文件夹路径。为此,我们应该通过获取前面描述的令牌来进行模拟
// Get token of the current user
IntPtr currentUserToken = ProcessStarter.GetCurrentUserToken();
// Get user ID by the token
WindowsIdentity currentUserId = new WindowsIdentity(currentUserToken);
// Perform impersonation
WindowsImpersonationContext impersonatedUser = currentUserId.Impersonate();
// Get path to the ”My Documents”
String myDocumentsPath =
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
// Make everything as it was
impersonatedUser.Undo();
现在,服务可以从“sample.xml”文件中读取数据
String sampleXmlFilePath = Path.Combine(myDocumentsPath,”sample.xml”);
XmlDocument oXmlDocument = new XmlDocument();
oXmlDocument.Load(sampleXmlFilePath);
XPathNavigator oPathNavigator = oXmlDocument.CreateNavigator();
XPathNodeIterator oNodeIterator =
oPathNavigator.Select("/SampleElement/Data");
oNodeIterator.MoveNext();
String receivedData = oNodeIterator.Current.Value;
通过文本文件进行数据交换非常简单,但它存在一些缺点。磁盘空间可能不足,用户可以直接干预数据记录过程等。因此,让我们考虑其他方法。
事件
在只需要传输“是/否”类型信息(回答对话框问题、服务是否应该停止的消息等)的简单情况下,我们可以使用事件。让我们看一个例子。某个应用程序“sample”的功能应该在某个点暂停,直到服务发出继续命令。
“sample”应用程序通过前面考虑的ProcessStarter
类(在C#中)从服务启动
ProcessStarter sampleProcess = new ProcessStarter();
sampleProcess.ProcessName = "sample";
sampleProcess.ProcessPath = @"C:\Base\sample.exe";
sampleProcess.Run();
现在,我们在“sample”应用程序中需要停止并等待服务命令的点创建一个全局事件SampleEvent
。我们暂停线程直到信号到来
using System.Threading;
EventWaitHandle sampleEventHandle =
new EventWaitHandle(false, EventResetMode.AutoReset,
"Global\\SampleEvent");
bool result = sampleEventHandle.WaitOne();
我们在服务中需要发送命令给应用程序的点打开全局事件SampleEvent
。我们将此事件设置为信号模式
EventWaitHandle handle =
EventWaitHandle.OpenExisting("Global\\SampleEvent");
bool setResult = handle.Set();
应用程序收到此信号并继续其功能。
命名管道
如果我们谈论交换过程中的大量数据,我们可以使用命名管道技术。我们必须提到以下代码是用C++提供的,因为C#中的命名管道工作类仅从.NET Framework 3.5开始引入。如果您想了解如何使用这些新的.NET命名管道工具,您可以阅读,例如,这篇文章:http://social.msdn.microsoft.com/Forums/en-US/csharpgeneral/thread/23dc2951-8b59-48e4-89fe-d2b435db48c6。
假设应用程序需要定期向服务发送一定数量的无符号整数。
在这种情况下,我们可以在服务端打开命名管道,然后在单独的线程中监视其状态,以便在数据到来时读取和处理。因此,我们在服务代码中创建管道DataPipe
HANDLE CreatePipe()
{
SECURITY_ATTRIBUTES sa;
sa.lpSecurityDescriptor =
(PSECURITY_DESCRIPTOR)malloc(SECURITY_DESCRIPTOR_MIN_LENGTH);
if (!InitializeSecurityDescriptor(sa.lpSecurityDescriptor,
SECURITY_DESCRIPTOR_REVISION))
{
DWORD er = ::GetLastError();
}
if (!SetSecurityDescriptorDacl(sa.lpSecurityDescriptor,
TRUE, (PACL)0, FALSE))
{
DWORD er = ::GetLastError();
}
sa.nLength = sizeof sa;
sa.bInheritHandle = TRUE;
// To know the maximal size of the received data
// for reading from the pipe buffer
union maxSize
{
UINT _1;
};
HANDLE hPipe = ::CreateNamedPipe((LPSTR)"\\\\.\\pipe\\DataPipe",
PIPE_ACCESS_INBOUND, PIPE_TYPE_MESSAGE |
PIPE_READMODE_MESSAGE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES, sizeof maxSize,
sizeof maxSize, NMPWAIT_USE_DEFAULT_WAIT, &sa);
if (hPipe == INVALID_HANDLE_VALUE)
{
DWORD dwError = ::GetLastError();
}
return hPipe;
}
我们还创建一个函数来检查线程状态并在需要时执行读取
unsigned int __stdcall ThreadFunction(HANDLE& hPipe)
{
while (true)
{
BOOL bResult = ::ConnectNamedPipe(hPipe, 0);
DWORD dwError = GetLastError();
if (bResult || dwError == ERROR_PIPE_CONNECTED)
{
BYTE buffer[sizeof UINT] = {0};
DWORD read = 0;
UINT uMessage = 0;
if (!(::ReadFile(hPipe, &buffer, sizeof UINT, &read, 0)))
{
unsigned int error = GetLastError();
}
else
{
uMessage = *((UINT*)&buffer[0]);
// The processing of the received data
}
::DisconnectNamedPipe(hPipe);
}
else
{
}
::Sleep(0);
}
}
最后,启动一个带有ThreadFunction()
函数的单独线程
unsigned int id = 0;
HANDLE pipeHandle = CreatePipe();
::CloseHandle((HANDLE)::_beginthreadex(0, 0, ThreadFunction,
(void*)pipeHandle, 0, &id));
现在,我们转到应用程序端,并通过命名管道组织向服务发送数据。
SendDataToService(UINT message)
{
HANDLE hPipe = INVALID_HANDLE_VALUE;
DWORD dwError = 0;
while (true)
{
hPipe = ::CreateFile((LPSTR)"\\\\.\\pipe\\DataPipe",
GENERIC_WRITE, 0, 0, OPEN_EXISTING, 0, 0);
dwError = GetLastError();
if (hPipe != INVALID_HANDLE_VALUE)
{
break;
}
// If any error except the ERROR_PIPE_BUSY has occurred,
// we should return FALSE.
if (dwError != ERROR_PIPE_BUSY)
{
return FALSE;
}
// The named pipe is busy. Let’s wait for 20 seconds.
if (!WaitNamedPipe((LPSTR)"\\\\.\\pipe\\DataPipe", 20000))
{
dwError = GetLastError();
return FALSE;
}
}
DWORD dwRead = 0;
if (!(WriteFile(hPipe, (LPVOID)&message, sizeof UINT, &dwRead, 0)))
{
CloseHandle(hPipe);
return FALSE;
}
CloseHandle(hPipe);
::Sleep(0);
return TRUE;
}
结论
对于Windows Vista中服务和应用程序之间的交互问题,没有唯一的正确解决方案。有很多机制,您应该根据具体问题选择合适的机制。不幸的是,许多此类交互组织的变体已超出本文的范围。例如,在这篇文章中讨论了在C#中使用某些此类技术:https://codeproject.org.cn/KB/threads/csthreadmsg.aspx。
为了更深入地了解这个问题以及Windows Vista开发中的许多功能,我还推荐Michael Howard、David LeBlanc的书 - Writing Secure Code for Windows Vista (Microsoft Press, 2007)。