65.9K
CodeProject 正在变化。 阅读更多。
Home

Vista UAC:权威指南

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (105投票s)

2007年6月13日

CPOL

29分钟阅读

viewsIcon

786513

downloadIcon

13052

了解UAC如何在后台运行。使用Elevate包启动多个提升权限的进程,但仅从非提升权限的进程显示一个UAC提升对话框。

引言

当我开始这个项目时,我没想到会花两周时间完成。好吧,我终于在这里写完了整个文章部分。别担心,我做了非常好的笔记。我将把这篇文章分成三个主要部分:第一部分是一个广泛的概述,适合那些真正不在乎UAC的细节,只想看重点内容的人。第二部分将深入到非常棘手的细节……适合那些享受信息过载的人。最后一个部分涵盖UAC永久链接——加载多个提升权限的DLL,执行多个提升权限的函数,以及启动多个提升权限的进程——所有这些只需从一个非提升权限的进程中显示一个UAC对话框。现在,废话不多说,进入正文。

全局概览

啊。Vista。关于它,有很多话要说。有些好,有些坏,但这并不重要。Vista看起来很漂亮,有新的API,还有UAC(Microsoft TechNetWikipedia)。

啊。UAC。对于缩写困难症患者来说,它代表用户账户控制。许多现有应用程序的“祸根”,以及我最终创建Elevate包的原因。最初我是为我自己的用户创建的,但我想其他开发者也需要它。

我们都希望认为我们的应用程序在Vista下能自动运行。然而,仅仅在Win95/98/Me/NT/2000/XP/2003后面加上Vista后,随之而来的残酷现实迫使我们真正采取行动。如果你正在阅读这篇文章,我只能假设你已经遇到了UAC引入的几个应用程序部署障碍之一。

本文的大部分内容涵盖了UAC的运行方式以及Elevate包的一半功能——CreateProcessElevated()是如何实现的。Elevate包的另一半是为了解决非提升权限应用程序对UAC的主要烦恼:为了执行只能在提升权限进程中完成的操作,需要不断通过UAC对话框。如果你发现自己处于这种情况,但又不想将整个应用程序切换为仅以提升权限运行,那么这篇文章也适合你。

在Google上搜索UAC通常会找到某个人的博客,讨论如何使用带有未记录的“runas”动词的ShellExecuteEx()来强制进程以提升权限启动。或者,当用户运行可执行文件时,修改清单文件也可以达到同样的效果。这里CodeProject上还有一篇文章如何绕过ShellExecuteEx(),通过使用NT服务来完成任务,但它存在各种问题,除了明显的与系统安全相关的问题。

但如果你和我一样,ShellExecuteEx() **不是**一个选项,而且我也不想绕过UAC——微软设置UAC是有原因的,我想好好遵守。我有一个庞大的库,并且严重依赖CreateProcess()。不幸的是,微软没有为像我这样的人提供CreateProcessElevated() API。幸运的是,我找到了一种方法来创建一系列功能几乎完整的CreateProcess...Elevated() API。

在开始讲CreateProcessElevated()之前,我们先来看一段使用ShellExecuteEx()的代码。

  SHELLEXECUTEINFOA TempInfo = {0};

  TempInfo.cbSize = sizeof(SHELLEXECUTEINFOA);
  TempInfo.fMask = 0;
  TempInfo.hwnd = NULL;
  TempInfo.lpVerb = "runas";
  TempInfo.lpFile = "C:\\TEMP\\UAC\\Test.exe";
  TempInfo.lpParameters = "";
  TempInfo.lpDirectory = "C:\\TEMP\\UAC\\";
  TempInfo.nShow = SW_NORMAL;

  ::ShellExecuteExA(&TempInfo);

这就是你基本的、普通的、强制Vista提升此进程的函数调用。正如之前所说的,这里的关键是“runas”。据我所知,“runas”动词在MSDN库中是完全未记录的。至少,我在研究过程中从未找到过任何关于它的引用。

我在这里声明, wherever possible,尽量避免使用“runas”动词,并优先考虑清单文件的修改。这让我想到,呃,清单修改。

如果你从未用过清单文件,你可能生活在控制台领域。或者一个洞穴。或者两者兼有。我通常只在需要让Windows加载程序知道加载最新的公共控件时使用清单。要通过所谓的“并行组装”(SxS,你可能已经注意到C:\WINDOWS中的“assembly”目录并想知道那是什么)加载公共控件6及更高版本(主题支持),需要清单文件。这是微软解决DLL地狱的解决方案,并且通常有效。

清单文件就是一个普通的XML文件。让我们看看你通常的、支持公共控件6的清单文件。

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" 
          manifestVersion="1.0"> 
<dependency> 
    <dependentAssembly> 
        <assemblyIdentity 
            type="win32" 
            name="Microsoft.Windows.Common-Controls" 
            version="6.0.0.0" 
            processorArchitecture="X86" 
            publicKeyToken="6595b64144ccf1df" 
            language="*" 
        /> 
    </dependentAssembly> 
</dependency> 
</assembly>

互联网告诉我们,对于Vista下的管理员权限,修改上述文件为:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" 
          manifestVersion="1.0"> 
<dependency> 
    <dependentAssembly> 
        <assemblyIdentity 
            type="win32" 
            name="Microsoft.Windows.Common-Controls" 
            version="6.0.0.0" 
            processorArchitecture="X86" 
            publicKeyToken="6595b64144ccf1df" 
            language="*" 
        /> 
    </dependentAssembly> 
</dependency> 
<v3:trustInfo xmlns:v3="urn:schemas-microsoft-com:asm.v3">
  <v3:security>
    <v3:requestedPrivileges>
      <v3:requestedExecutionLevel level="requireAdministrator" />
    </v3:requestedPrivileges>
  </v3:security>
</v3:trustInfo>
</assembly>

<BZZZZZZZT!>

错误答案!显然,格式错误的v3清单文件会导致Windows XP SP2崩溃。而且不是普通的崩溃。是蓝屏死机(BSOD),并伴随重启。如果不实际拆开CreateProcess(),这可能是由于内核模式下的XML解析器崩溃(我所知道的让XP蓝屏的唯一方法是让内核在ring 0崩溃)。此外,v2清单明显更稳定,并且仍然有效。因此,为了最大限度地稳定,清单文件的正确格式应该是:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" 
          manifestVersion="1.0"> 
<dependency> 
    <dependentAssembly> 
        <assemblyIdentity 
            type="win32" 
            name="Microsoft.Windows.Common-Controls" 
            version="6.0.0.0" 
            processorArchitecture="X86" 
            publicKeyToken="6595b64144ccf1df" 
            language="*" 
        /> 
    </dependentAssembly> 
</dependency> 
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
    <security>
        <requestedPrivileges>
            <requestedExecutionLevel 
                level="requireAdministrator" 
                uiAccess="false"/>
        </requestedPrivileges>
    </security>
</trustInfo>
</assembly>

requestedExecutionLevel的“level”选项可以是‘asInvoker’、‘requireAdministrator’或‘highestAvailable’。我将在本文后面讨论“uiAccess”选项。

对于那些沉迷于COM/DCOM/什么的并且严重依赖该技术的人来说,你可能已经**远超**我的水平了,但我还是会为你发布一些冗余的、进程外提升的moniker代码。

HRESULT CreateElevatedComObject(HWND hwnd, 
                                REFCLSID rclsid, 
                                REFIID riid, 
                                __out void ** ppv)
{
    BIND_OPTS3 bo;
    WCHAR  wszCLSID[50];
    WCHAR  wszMonikerName[300];

    StringFromGUID2(rclsid, wszCLSID, cntof(wszCLSID)); 
    HRESULT hr = StringCchPrintf(wszMonikerName, 
                                 cntof(wszMonikerName)), 
                                 L"Elevation:Administrator!new:%s", 
                                 wszCLSID);
    if (FAILED(hr))
        return hr;
    memset(&bo, 0, sizeof(bo));
    bo.cbStruct = sizeof(bo);
    bo.hwnd = hwnd;
    bo.dwClassContext = CLSCTX_LOCAL_SERVER;
    return CoGetObject(wszMonikerName, &bo, riid, ppv);
}

// cntof() is defined as:
#define cntof(a) (sizeof(a)/sizeof(a[0]))

我真的不知道那段代码是做什么的。但显然,它神奇地美味。我在ShellExecuteEx()中确实看到了“Elevation:Administrator!new:”这个字符串,所以我只能假设这段代码有效。我尽量避免使用COM, wherever and whenever possible。COM有点像瘟疫。

用户界面集成

如果你要进行进程提升,你必须做得对,并且做得有风格。Vista的盾牌图标几乎到处都是,只要是需要管理员权限的任务。例如,任务管理器不会显示所有用户的所有正在运行的进程,直到你点击带有盾牌图标的按钮并经过UAC提升过程。

幸运的是,微软让在对话框按钮上放置盾牌图标变得容易。

GetDlgItem(IDC_SOMEBUTTONID)->SendMessage(BCM_SETSHIELD, 0, TRUE);
// OR use the new macro:
Button_SetElevationRequiredState(ButtonHWnd, fRequired);

不幸的是,将盾牌图标放在其他地方却很麻烦。你可以调用新的Vista独有函数SHGetStockIconInfo()来将其提取为HICON

HICON ShieldIcon;
SHSTOCKICONINFO sii = {0};
sii.cbSize = sizeof(sii);
SHGetStockIconInfo(SIID_SHIELD, SHGFI_ICON | SHGFI_SMALLICON, &sii);
ShieldIcon = sii.hIcon;

然而,除非你正在设计一个仅限Vista的应用程序,否则你需要为SHGetStockIconInfo()执行LoadLibrary()/GetProcAddress()的操作。

或者,可以使用LoadIcon()IDI_SHIELD来获取HICON,**但是**这会加载一个“低质量”的盾牌图标。新的Vista APILoadIconMetric()是一个更好的解决方案,因为它加载的是“高质量”的盾牌图标,这对于高DPI显示器来说会更好。

UAC的怪异之处

到目前为止,我只介绍了UAC最突出的部分,但UAC远不止显示一个对话框给用户。它是一种生活方式。或者类似的东西。而且,就像生活中大多数事物一样,UAC可能非常怪异。

我们中的许多人都有现有的应用程序。其中一些甚至是我们编写得糟糕的应用程序,会写入“Program Files”目录。有些应用程序表现非常糟糕,会同时写入Windows目录和HKLM注册表键。

UAC将表现不佳(非提升权限)的应用程序视为非婚生子女。对于每个表现不佳的应用程序,都有一个叫做“虚拟存储”的东西。它包括被认为是对于非提升权限进程来说糟糕的写入位置而写入的文件和注册表键。当发生写入操作时,操作系统会将原始文件复制到用户的虚拟存储中,并将所有请求重定向到该位置。因此,应用程序对虚拟存储中的文件/注册表键进行更改,而不是它原本打算更改的位置。但仅限于该用户。其他用户将看到原始文件或其虚拟存储中的副本。

但是等等!事情变得更奇怪了。假设应用程序要删除它在Program Files中修改过的文件。好吧,操作系统会将请求重定向到虚拟存储。然而,即使文件已被删除,如果应用程序再次访问,它仍然可以看到文件仍然存在。一旦从虚拟存储中移除,操作系统就允许应用程序看到原始文件。然而,如果应用程序再次尝试删除该文件,它将导致错误。这看起来有道理,但却很奇怪。

UAC还会导致许多其他小问题:交互式服务(GUI类型)完全被破坏(即,从NT服务创建窗口),管理员共享出现重大问题,各种获取或创建令牌的函数(例如,HANDLE hToken)会受到拆分令牌(例如,LogonUser)的影响,以及并行隔离问题

这自然会引出拆分令牌。拆分令牌就是很奇怪。当你登录Windows Vista及更高版本(显而易见)时,你会得到一个拆分令牌。基本上,登录架构(为Vista又变了……)会获取你最初非常强大的用户令牌,并创建一个第二个令牌,从中剥离所有管理员权限。这个第二个令牌用于启动所有应用程序,基本上就像XP中的有限用户帐户(LUA)一样。当UAC提示提升权限并你接受时,第一个令牌用于创建进程,而不是第二个令牌。本质上,UAC提示要求的是:“你真的想让我使用你超级强大的管理员令牌来启动这个应用程序吗?”

另一个奇怪的UAC事情是提升对话框。如果你让它在那里待很长时间,它会自动取消。我在研究时发现了这一点。

UAC的最后一个怪异之处是,一旦一个进程作为提升权限的进程启动,从该提升权限的进程启动一个非提升权限的进程会变得非常困难。这对于软件安装程序的作者来说是一个主要的烦恼。我们这些软件开发者喜欢让用户在安装结束时试用软件,这样用户就有可能真正使用该软件并更有可能购买它。有一篇文章展示了如何乘坐Vista电梯,通过使用全局钩子并钩住Explorer.exe以较低的完整性级别创建进程。

CreateProcess() 失败,返回 ERROR_ELEVATION_REQUIRED

这些就是UAC的基础知识,集中在一处。对许多人来说,ShellExecuteEx()、修改后的清单、切换到HKCU以及不在硬盘上写不好的地方,都是“足够好”的解决方案。然而,有些人想知道更多。有些人需要CreateProcess()及其所有功能(还有一些人可能需要自定义动词的ShellExecute()/ShellExecuteEx())。

现在,我们来谈谈Elevate包和CreateProcessElevated()

CreateProcess()对于需要通过清单文件进行提升的Vista进程会严重失败。需要注意的是,清单文件并没有真正说“你必须使用管理员令牌来启动这个进程”。相反,它说“你不能使用低于这些权限的令牌来启动这个进程”。因此,从CreateProcess()返回的错误消息ERROR_ELEVATION_REQUIRED(740)有些误导。

无论如何,如果你正在阅读这篇文章,我只能假设你迫切需要一个CreateProcessElevated()解决方案。这就是Elevate包的用途。Elevate包(Elevate_BinariesAndDocs.zip)包含两个组件以及全面的文档:Elevation API DLL(Elevate.dll)和Elevation Transaction Coordinator(Elevate.exe)。两者都必须放在同一个目录下才能正常工作。Elevation API DLL导出以下函数:

Link_Create()
Link_CreateAsUser()
Link_CreateWithLogon()
Link_CreateWithToken()
Link_Destroy()
Link_CreateProcessA()
Link_CreateProcessW()
Link_ShellExecuteExA()
Link_ShellExecuteExW()
Link_ShellExecuteA()
Link_ShellExecuteW()
Link_LoadLibraryA()
Link_LoadLibraryW()
Link_SendData()
Link_GetData()
Link_SendFinalize()

CreateProcessElevatedA()
CreateProcessElevatedW()
CreateProcessAsUserElevatedA()
CreateProcessAsUserElevatedW()
CreateProcessWithLogonElevatedW()
CreateProcessWithTokenElevatedW()
SH_RegCreateKeyExElevatedA()
SH_RegCreateKeyExElevatedW()
SH_RegOpenKeyExElevatedA()
SH_RegOpenKeyExElevatedW()
SH_RegCloseKeyElevated()
ShellExecuteElevatedA()
ShellExecuteElevatedW()
ShellExecuteExElevatedA()
ShellExecuteExElevatedW()

IsUserAnAdmin()

你会注意到,提升的API名称与其非提升的对应项非常相似(例如,CreateProcess()CreateProcessElevated())。此包还包括ANSI和Unicode版本(A vs. W)。你可能会注意到IsUserAnAdmin()已经是从Shell32.dll导出的一个函数,但MSDN说它可能会被移除IsUserAnAdmin()目前会调用Shell32.dll的版本,但如果它消失了,也可以回退到内部代码。

继续。你现有的代码可能看起来像这样:

Result = CreateProcess(...ParameterList...);
if (!Result)  return FALSE;
...Do something with process like WaitForSingleObject()...
::CloseHandle()'s;

这段代码工作得很好,直到你试图启动一个需要提升权限的进程。要使用Elevate包,只需将其放在系统上,然后LoadLibrary()/GetProcAddress()来获取CreateProcessElevatedA()函数。

Result = CreateProcess(...ParameterList...);  // Existing line of code.
if (!Result && GetLastError() == ERROR_ELEVATION_REQUIRED)
{
  HMODULE LibHandle = LoadLibrary("Elevate.dll");
  if (LibHandle != NULL)
  {
    DLL_CreateProcessElevated =
           (typecast)GetProcAddress("CreateProcessElevatedA");
    if (DLL_CreateProcessElevated)
    {
      ...Custom handle changes here *...
      Result = DLL_CreateProcessElevated(...ParameterList...);
      ...Custom handle connections here *...
    }
    FreeLibrary(LibHandle);
  }
}
if (!Result)  return FALSE;
// Continue as usual with existing code...

“...ParameterList...”选项**几乎**相同。你唯一需要改变参数的情况是在你传入的STARTUPINFO结构中使用STARTF_USESTDHANDLES标志。如果你进行标准句柄(stdin,stdout,stderr)重定向,事情可能会有点棘手。请阅读文档,但简而言之,你需要熟悉命名管道并完整阅读本文的其余部分。

请注意,你**必须**使用LoadLibrary()/GetProcAddress()。该DLL故意在Vista之前的任何Windows操作系统上加载失败。

ShellExecute...Elevated()?!

你们中的一些人可能在想,为什么需要ShellExecuteElevated() API。这有两个原因。第一个原因是“runas”动词引入的一个特殊问题。lpVerb/lpOperation参数一次只允许使用一个动词。所以,如果有人需要强制一个(通常是非提升权限运行的)进程以提升权限运行,并且,例如,使用“print”动词,那么常规的ShellExecute()/ShellExecuteEx()就无法胜任。

第二个原因是,因为有人可能特别需要从提升权限的环境中运行ShellExecute()/ShellExecuteEx()命令。这两种情况都无法实现。

现在,我承认ShellExecuteElevated() API的需求将非常罕见,但我将其包含在内是为了完整性。

演示应用程序

为了更好的地方放置它,演示应用程序是CreateProcessElevated()的实际应用示例。要使用演示,首先下载它,并将其解压到一个你可以从命令提示符访问的目录中。然后,启动一个非提升权限的命令提示符,并转到你解压文件的目录。输入“TestParent”,然后按Enter。接下来应该如下所示:

Screenshot - Elevate01.png

Screenshot - Elevate02.png

Screenshot - Elevate03.png

忽略对我很酷的短语知识的明显引用,发生的事情确实令人印象深刻。通常,从非提升权限的控制台程序启动的提升权限的控制台程序会有一个单独的控制台窗口。在这种情况下,TestChild.exe(提升权限)和TestParent.exe(非提升权限)共享同一个控制台。此外,TestChild.exe的stderr被路由到TestParent.exe。这是通过非常仔细地筛选大量文档和一些实验实现的。

另外,虽然程序没有显示,但TestChild.exeTestParent.exe共享相同的环境变量。

核心细节

在讨论Elevate.dllElevate.exe如何工作以使演示成为可能之前,我必须介绍一些关于UAC提升如何工作的更棘手的细节。请耐心听我说,这会非常深入。

当我开始这个项目时,我想避免使用ShellExecuteEx(),所以要做到这一点,我必须弄清楚是什么让这个函数“运转”。我的第一个想法是,“嗯,他们最终肯定会调用CreateProcess()及其相关函数。所以,这个调用有什么诀窍,对吧?”我的第一个目的地是Detours的traceapi.dll文件。我将其挂入一个测试进程,并使用ShellExecuteEx() API,结果……什么都没有。我以为traceapi.dll坏了,所以我花了一天时间才弄清楚,也许ShellExecuteEx()根本没有使用CreateProcess()

所以,下一步是使用Visual Studio Disassembler深入挖掘实际调用。我很快意识到我需要去符号存储区获取VistaShell32.dll和其他相关DLL的符号。我知道微软在符号服务器上会处理他们的符号,以剔除他们不希望人们知道的东西。我希望UAC提升不会是其中一部分。结果,他们没有删除这些信息,或者只是忘了删除。无论如何,你可以永远感激这些信息的存在。

通过调试器逐步执行ShellExecuteEx()非常混乱。事实上,我仍然不确定有些东西是做什么的。如果你跟着调试器走,你的调用堆栈最终会是这样:

     shell32.dll!CShellExecute::ExecuteNormal()  + 0x7a
     shell32.dll!ShellExecuteNormal()  + 0x33
     shell32.dll!_ShellExecuteExW@4()  + 0x42
     shell32.dll!_ShellExecuteExA@4()  + 0x4a
     ShellExecuteTest.exe!main()  Line 18 + 0xc
     ShellExecuteTest.exe!mainCRTStartup()  Line 259 + 0x19
     kernel32.dll!@BaseThreadInitThunk@12()  + 0x12
     ntdll.dll!__RtlUserThreadStart@8()  + 0x27

看起来很有希望,对吧?嗯,对于一个正常函数来说,这可能就是你执行命令的地方。然而,在这种情况下,ShellExecuteEx()才刚刚开始……启动一个新线程。说真的。要启动一个新进程,就会启动一个新线程。这很令人作呕。所以,我们通过调试器深入到新线程中,直到调用堆栈看起来像这样:

     shell32.dll!CExecuteApplication::Execute()  + 0x22
     shell32.dll!CExecuteAssociation::_DoCommand()  + 0x5b
     shell32.dll!CExecuteAssociation::_TryApplication()  + 0x32
     shell32.dll!CExecuteAssociation::Execute()  + 0x30
     shell32.dll!CShellExecute::_ExecuteAssoc()  + 0x82
     shell32.dll!CShellExecute::_DoExecute()  + 0x4c
     shell32.dll!CShellExecute::s_ExecuteThreadProc()  + 0x25
     shlwapi.dll!WrapperThreadProc()  + 0x98
     kernel32.dll!@BaseThreadInitThunk@12()  + 0x12
     ntdll.dll!__RtlUserThreadStart@8()  + 0x27

现在,这看起来很有希望,对吧?嗯,要达到这一点,是一个巨大的混乱,需要大约五个小时的按F10和F11。你必须经过**一大堆** COM对象。是的,没错。COM现在涉及启动新进程。这意味着要引入一堆DLL。但猜猜怎么着?我们还没完。

Execute()方法内部,调用了CExecuteApplication::_VerifyExecTrust()。它使用COM,并且消耗**大量** CPU(以及大量的代码上的明显且不必要的重复)。我差不多放弃了弄清楚它做了什么。我的普遍印象是,它最有可能与从互联网下载的EXE启动时收到的弹出对话框有关(IZoneIdentifier)。可能是在查找名为'Zone.Identifier'的NTFS流是否存在,以便弹出你在执行下载文件之前看到的那个精美(且烦人)的对话框。

我从_VerifyExecTrust()退了出来,并沿着唯一剩下的路径走。调用堆栈变得非常滑稽:

     shell32.dll!AicpMsgWaitForCompletion()  + 0x36
     shell32.dll!AicpAsyncFinishCall()  + 0x2c
     shell32.dll!AicLaunchAdminProcess()  + 0x2ee
     shell32.dll!_SHCreateProcess()  + 0x59d0
     shell32.dll!CExecuteApplication::_CreateProcess()  + 0xac
     shell32.dll!CExecuteApplication::_TryCreateProcess()  + 0x2e
     shell32.dll!CExecuteApplication::_DoApplication()  + 0x3c
     shell32.dll!CExecuteApplication::Execute()  + 0x33
     shell32.dll!CExecuteAssociation::_DoCommand()  + 0x5b
     shell32.dll!CExecuteAssociation::_TryApplication()  + 0x32
     shell32.dll!CExecuteAssociation::Execute()  + 0x30
     shell32.dll!CShellExecute::_ExecuteAssoc()  + 0x82
     shell32.dll!CShellExecute::_DoExecute()  + 0x4c
     shell32.dll!CShellExecute::s_ExecuteThreadProc()  + 0x25
     shlwapi.dll!WrapperThreadProc()  + 0x98
     kernel32.dll!@BaseThreadInitThunk@12()  + 0x12
     ntdll.dll!__RtlUserThreadStart@8()  + 0x27

喜欢给函数起的名字。它似乎在鼓励你看看接下来会发生什么。_DoExecute(), _Execute(), _TryApplication(), _DoCommand()……好了,现在我们要创建进程了。哦,等等,忘了说,**现在**我们要创建进程了。哦,抱歉,**_现在_**我们要创建进程了……营销引擎已经进入了源代码。可怜的开发者。我为你们每天都要处理这种混乱的开发者感到难过。

总之,有问题的函数是AicLaunchAdminProcess()。一旦我们到达AicpMsgWaitForCompletion(),就太晚了。我花了近两天时间挠头才弄清楚,如何进入MsgWaitForMultipleObjects()会导致UAC显示提升对话框,并且在点击接受/取消后继续,就好像什么都没发生一样。

结果发现,不仅启动了一个新线程并实例化了六个COM对象,还引入了RPC。RPC(缩写困难症患者的远程过程调用)是一项相当古老的技术,但微软主要将其用于NT服务。它与MIDL结合,允许你,嗯,调用远程过程。目标函数可能在另一台计算机上,也可能在同一台计算机上。有点像一个未被利用的安全漏洞,因为它也是一项晦涩的技术,很少有人知道如何使用。

而且,有趣的事情在这里发生了。RPC调用的是Vista中一个全新的NT服务,名为AppInfo(UUID“{201ef99a-7fa0-444c-9399-19ba84f12a1a}”)。AppInfo就是魔法发生的地方。但在我讲魔法之前,需要简要讨论一下会话(Sessions)和完整性级别(Integrity Levels)。

Vista会话和完整性级别

Vista的变化以及UAC工作方式的一部分是引入了所谓的“会话”。它们有时也被称为“安全桌面”。Vista有两个会话,但理论上可以有更多。Session 0和Session 1是官方名称。Session 0是所有NT服务所在的地方。Session 1是“WinSta0\Default”桌面及其Explorer和用户应用程序所在的地方。需要澄清的是,窗口站(Window Stations)**不是**会话。

需要注意的是,一些关于Vista UAC的文档含蓄地声称每个会话应该与其他会话完全隔离。这是不正确的。最有可能的是,作者指的是窗口消息或早期测试版。官方微软文档Windows Vista中Session 0隔离对服务和驱动程序的影响,建议使用RPC和命名管道在不同会话上运行的进程之间进行通信。RPC、命名管道和套接字都是Vista下经过肯定的进程间通信(IPC)机制。

Vista中UAC的另一个主要变化是所谓的“完整性级别”(ILs)。有四种完整性级别:系统(NT服务)、高(提升权限进程)、中(大多数用户进程)、低(如受保护模式IE的进程)。每个创建的对象都有一个关联的IL,并且在DACL之前会检查IL。进程/线程IL在授予对对象的访问权限之前会与对象IL进行检查。需要注意的是,由提升权限进程和系统进程创建的对象,默认情况下,具有中等完整性级别。(对于观察力敏锐的人来说,最后一句话是Elevate包起作用的关键。)

AppInfo和consent.exe

AppInfo显然是UAC提升的关键。ShellExecuteEx()通过RPC调用将所有提升请求转发给AppInfo NT服务。AppInfo接着调用一个在SYSTEM上下文中运行的可执行文件,称为“consent.exe”。顾名思义,这是弹出用户同意对话框的可执行文件。

然而,consent.exe**不是**你普通、日常的应用程序。当对话框激活时,你看到的是不是Session 1的WinSta0\Default。不。你看到的是Session 0上的一个桌面。因此,它被称为“安全桌面”。consent.exe拍摄屏幕快照,切换到Session 0桌面,将屏幕快照放在桌面上,并显示对话框。它**看起来**像Session 1的桌面。你只能点击对话框,因为那里实际上没有什么可以点击的。一旦你接受/取消,桌面会再次切换回Session 1,consent.exe退出。

AppInfo接着获取consent.exe的结果,并确定是否需要启动一个新进程(即,你接受了提升请求)。AppInfo然后使用登录用户在Session 1桌面上的完整管理员令牌(还记得那个拆分令牌吗?)创建一个具有高完整性级别的进程。如果你启动任务管理器,可以看到提升权限的进程确实是以当前用户身份运行的。我们知道它也在Session 1桌面运行,因为GUI窗口可以被创建、看到并进行交互。

要在不同会话的不同桌面上以当前用户身份创建进程是一个七阶段过程:

  1. AppInfo去和本地安全机构(Local Security Authority)对话,获取Session 1登录用户的提升权限令牌。
  2. AppInfo加载一个STARTUPINFOEX结构(Vista新增),并调用全新的Vista APIInitializeProcThreadAttributeList(),为其分配一个属性的空间。
  3. 调用OpenProcess()以获取发起RPC调用的进程的句柄。
  4. 调用UpdateProcThreadAttribute(),使用PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,并使用在第3步中获取的句柄。
  5. 调用CreateProcessAsUser(),使用EXTENDED_STARTUPINFO_PRESENT和第1步和第4步的结果。
  6. 调用DeleteProcThreadAttributeList()
  7. 收集结果,并清理句柄。

一旦AppInfo成功启动进程,它就会通过RPC接口将一些信息传回给调用ShellExecuteEx()的应用程序。ShellExecuteEx()会进行一些绕道操作,进行清理,最终通过整个函数调用链返回,关闭线程,并返回给调用者。

不使用ShellExecuteEx()的CreateProcessElevated()?

一旦我了解到ShellExecuteEx()大约在哪个环节进行RPC调用到AppInfo以及AppInfo是如何工作的,我就想知道是否有可能创建一个功能齐全的CreateProcessElevated(),而无需进行那个极其昂贵的ShellExecuteEx()调用。所谓功能齐全,是指我想要对STARTUPINFO结构进行完全控制,并支持STARTF_USESTDHANDLES标志。

这个过程花了大约三天时间完成,而且非常累人。我最终将一切缩小到AicLaunchAdminProcess()内部的几行汇编代码:

mov     edi, [ebp+VarStartupInfo]
mov     eax, [edi+_STARTUPINFOW.lpTitle]
mov     [ebp+VarStartupInfo_Title], eax
mov     eax, [edi+_STARTUPINFOW.dwX]
mov     [ebp+VarStartupInfo_X], eax
mov     eax, [edi+_STARTUPINFOW.dwY]
mov     [ebp+VarStartupInfo_Y], eax
mov     eax, [edi+_STARTUPINFOW.dwXSize]
mov     [ebp+VarStartupInfo_XSize], eax
mov     eax, [edi+_STARTUPINFOW.dwYSize]
mov     [ebp+VarStartupInfo_YSize], eax
mov     eax, [edi+_STARTUPINFOW.dwXCountChars]
mov     [ebp+VarStartupInfo_XCountChars], eax
mov     eax, [edi+_STARTUPINFOW.dwYCountChars]
mov     [ebp+VarStartupInfo_YCountChars], eax
mov     eax, [edi+_STARTUPINFOW.dwFillAttribute]
mov     [ebp+VarStartupInfo_FillAttr], eax
mov     eax, [edi+_STARTUPINFOW.dwFlags]
mov     [ebp+VarStartupInfo_Flags], eax
mov     cx, [edi+_STARTUPINFOW.wShowWindow]
mov     [ebp+VarStartupInfo_ShowWindow], cx
...
push    eax
push    ebx
push    0FFFFFFFFh
push    [ebp+hwnd]
lea     eax, [ebp+VarStartupInfo_Title]
push    eax
push    [ebp+hMemToWinSta0_Desktop]
push    [ebp+VarExpandedCurrDir]
push    [ebp+ArgCreationFlags]
push    [ebp+arg_8]  ; Probably bInheritHandles
push    [ebp+VarExpandedCommandLine]
push    [ebp+VarExpandedApplicationName]
push    StaticBindingHandle
lea     eax, [ebp+pAsync]
push    eax
call    _RAiLaunchAdminProcess@52

RAiLaunchAdminProcess()接受大量参数,然后通过一个非常糟糕的MIDL调用在幕后路由整个过程。你会注意到对[ebp+VarStartupInfo_Title]使用了lea(加载有效地址)。对于非汇编大师来说,这基本上是将一个数据块从Title的地址传递给ShowWindow。因此,这意味着只有有限的信息从STARTUPINFO传递到目标进程。这告诉我AppInfo处理的信息量不多。

此时,我放弃了自己做与AppInfo的RPC事情的想法,为浪费的几天哭了两秒钟,然后去弄清楚如何使用ShellExecuteEx()来实现我想要的功能。

通过ShellExecuteEx()路由

我很快决定创建一个DLL/EXE分离的方法。我的目标受众最初是我的客户群,所以它需要干净易懂。DLL会导出看起来和感觉都像Win32 API的函数。然后DLL会将每个函数的数据打包起来,并将其传递给EXE,EXE会解包这些数据,并以提升权限的进程身份执行正确的函数。

我的最初方法是通过SHELLEXECUTEINFOlpParameters成员来传递数据。令我惊讶的是,这样做会弹出一个极其无意义的错误消息:“传递给系统调用的数据区域太小。”(错误代码122)。经过大量实验,显然在lpParameters成员中发送超过2048字节(2K)的数据会导致该错误消息。

我的解决方案是从Elevate.dllElevate.exe传递进程ID和线程ID,并使用这些信息打开一个命名管道,然后连接到一个在Elevate.dll中运行的命名管道服务器来发送信息。

关于命名管道

但我现在就说得太远了。当我了解了完整性级别以及在高IL创建的对象只有中等完整性级别(与非提升权限进程相同)时,我获得了使用命名管道的想法。

但是管道是基于HANDLE的。这就是“陷阱”。还记得为了提升进程而进行的那些迂回的麻烦吗?嗯,虽然HANDLE**是**可继承的,但实际启动提升进程的是AppInfo,而不是调用ShellExecuteEx()的进程。而且,即使HANDLE可以传递,也存在Session 0到Session 1的屏障问题。所以,传递原始HANDLE值是行不通的。

有人可能会指出,AppInfo启动进程的方法确实会继承原始进程的句柄,并包括标准HANDLE(stdin,stdout,stderr)。然而,虽然这是真的,并且HANDLE**是**可继承的,但AppInfo需要用不能穿过Session 0到Session 1屏障的HANDLE来填充STARTUPINFO结构。

解决方案在于命名管道。命名管道有,嗯,名字。有时是ANSI,有时是Unicode,但那些名字是字符串。而且,与HANDLE不同,字符串**可以通过IPC传递**。CreateNamedPipe()和使用相同字符串的CreateFile()等价于一个指向同一管道的HANDLE。而且,因为两个对象都具有中等完整性级别,所以它也能工作。出于安全原因和DACL原因,DLL调用CreateNamedPipe(),EXE调用CreateFile()来连接命名管道。

MSDN库证实,使用命名管道启动进程进行重定向是可能的

Elevate如何工作

假设正在调用CreateProcessElevated()Elevate.dll被加载,并调用了该函数。就在调用ShellExecuteEx()之前,创建了一个命名管道和三个事件对象。Elevate.dll以提升权限进程的身份启动Elevate.exe。成功返回后(进程已启动),Elevate.dll等待Elevate.exe完成初始化。请注意,事件对象超时时间为10秒,然后退出。这是为了防止应用程序挂起。

一旦这两个进程同步起来,Elevate.dll会将发送给函数的打包数据,作为一个数据包发送给Elevate.exeElevate.exe接收数据包并解包。

从这里开始,Elevate.exe准备运行进程。它附加到启动Elevate.exe的进程的控制台。此外,我偶尔使用STARTF_USESTDHANDLES标志,Elevate.exe会获取命名管道名称,并为stdin、stdout和stderr设置适当的HANDLE,并构建其余的STARTUPINFO结构。附加到控制台可以访问该控制台的stdin、stdout和stderr HANDLE

之后,会以正确的参数执行正确的函数。唯一值得注意的是CREATE_SUSPENDED标志的使用。此标志启动进程时是挂起的。原因是将进程和主线程的信息发送回Elevate.dll。请记住,原始HANDLE无法传递,但进程和线程ID可以。另外,请记住,启动的进程可能生命周期很短,以至于它会在Elevate.dll能够打开适当的HANDLE进行同步之前退出。

然后Elevate.dll接收关于进程的信息,进行处理,然后触发事件,告知Elevate.exe它已收到并处理了信息。

Elevate.exe恢复进程(如果适用),然后退出。Elevate.dll返回,调用者恢复正常操作。

UAC永久链接

Elevation API DLL(Elevate.dll)导出以下函数:

Link_Create()
Link_CreateAsUser()
Link_CreateWithLogon()
Link_CreateWithToken()
Link_Destroy()
Link_CreateProcessA()
Link_CreateProcessW()
Link_ShellExecuteExA()
Link_ShellExecuteExW()
Link_ShellExecuteA()
Link_ShellExecuteW()
Link_LoadLibraryA()
Link_LoadLibraryW()
Link_SendData()
Link_GetData()
Link_SendFinalize()

现在,你可能在想,或者已经想过了,这些函数有什么用。UAC永久链接这个术语是我自己创造的。嘿,一点艺术性的许可也没什么坏处,对吧?

UAC永久链接到底是什么?假设你有一个应用程序,它在Windows XP上运行良好,但在Vista上却需要你创建一个单独的可执行文件来解决提升问题。你在需要提升权限的地方都贴上了盾牌图标,并发布了更新后的软件。

砰。一些用户抱怨UAC对话框使用过多。然而,你不想将整个应用程序转换为需要提升权限才能运行。这时UAC永久链接就派上用场了。

Link_Create()实例化一个UAC永久链接。它启动Elevate.exe,后者会显示UAC对话框。用户点击“允许”,然后Elevate.exe启动。从Link_Create()返回的HANDLE随后可以传递给其他Link_...()函数。当应用程序完成UAC永久链接的使用(例如,在程序结束时),调用Link_Destroy()Elevate.exe退出,所有资源都被清理。

这其中的可能性是无穷无尽的。一旦UAC对话框显示并被允许,你的非提升权限程序就可以在提升权限的进程空间中自由活动。

CreateProcessElevated()和其他导出函数实际上会实例化一个UAC永久链接,调用Link_CreateProcess()或其他合适的函数,然后调用Link_Destroy()

与其启动进程,不如考虑创建一个DLL。你甚至可以显示返回数据给非提升权限进程的对话框。这确实会变得有些复杂,因为所有发送/接收的数据都必须序列化和反序列化,但这可能值得节省启动多个进程的开销,并且与非提升权限应用程序的双向通信可能也很重要。如果你选择这条路,我建议查看Elevate的源代码以了解它是如何工作的。

如果你需要将一个或多个API钩子DLL加载到提升权限的进程空间中,DLL方法也同样有效。

UAC永久链接的好处在于,这种方法完全符合微软定义的UAC使用方式。

源代码

对于下载源代码的各位,请注意,源代码只是应用程序层(安全的C++术语)。不包含Base Library。提供源代码是为了以防有人发现bug,可以指出并可能修复。我还包括了它,以便你可以大致跟随本文。

更多.manifest问题

如前所述,可执行文件的关联清单文件可以有一个requestedExecutionLevel的“level”选项,可以是‘asInvoker’、‘requireAdministrator’或‘highestAvailable’。‘asInvoker’意味着可以使用非提升权限令牌或更高权限来启动进程。‘requireAdministrator’意味着可以使用提升权限或更高权限来启动进程。‘highestAvailable’意味着进程将以用户拆分令牌允许的最高级别启动。

我之前说过会讨论‘uiAccess’选项。‘uiAccess’是为了授予一小组非提升权限的进程访问提升权限进程用户界面的权限。这专门用于UI自动化任务(例如,宏录制和回放工具)。大多数情况下,你希望将‘uiAccess’选项指定为‘false’。

但是如果你想将其指定为‘true’呢?好吧,准备花钱吧。你会看到Verisign代码签名证书的推荐,但它们的价格高达惊人的金额(每年400美元)。或者,你可以尝试这些公司之一……但请确保它们支持代码签名(可能还有时间戳——“是”越多越好)。

根据评论,Tucows.com提供Comodo代码签名证书,每年75美元。很不错。

如果你只需要为个人用途试用一些东西,你不需要花钱,但你需要最新的Authenticode工具和一个包含uiAccess设置为‘true’的清单的可执行文件。如果你设置好了,请从提升权限的命令提示符运行以下命令:

1) Create a trusted root certificate:

   Browse to the folder that you wish to contain a copy of the certificate.
   In the command shell, execute the following commands:

   makecert -r -pe -n "CN=Test Certificate" -ss PrivateCertStore testcert.cer

   certmgr.exe -add testcert.cer -s -r localMachine root

2) Sign your file:

   SignTool sign /v /s PrivateCertStore /n "Test Certificate"
            /t http://timestamp.verisign.com/scripts/timestamp.dll
            YourApp.exe

   Where YourApp.exe is your application.

注意:如果你使用受信任的代码签名证书颁发机构(CA)的证书对可执行文件进行签名,橙色的UAC提升对话框会变成暗灰色。

IShellExecuteHook vs. ShellExecuteWithHookElevated()

对于使用IShellExecuteHook DLL的人来说,你可能很清楚,微软在Vista中“弃用”了这个COM接口,并且目前没有提供替代方案。它默认是关闭的,因为旧的钩子会导致Vista shell崩溃。可以通过设置来启用该接口:

[HKLM or HKCU]\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer

EnableShellExecuteHooks=1 (DWORD)

编写IShellExecuteHook DLL的替代方法是钩住ShellExecute()ShellExecuteEx()IsUserAnAdmin()。当这些函数调用IsUserAnAdmin()时,重写ShellExecute()调用以使用ShellExecute...Elevated()调用。这种方法并不完美,因为各种COM接口会绕过API直接访问提升机制。你还需要非常小心,避免陷入无限循环(Elevate包调用ShellExecuteEx())。

另一种方法是结合使用这两种方法。编写一个IShellExecuteHook DLL,然后也钩住IsUserAnAdmin()。这更有可能捕获所有即将进行UAC操作的内容,但然后你需要担心COM接口是否会起作用。

其他参考资料

历史

  • 2007年6月:初次发布。
  • 2007年7月:添加了ShellExecute...Elevated()和关于Hook DLL的讨论。
  • 2007年8月:修正了关于会话的讨论。小幅度文章清理。
  • 2008年3月:Elevate包重大重写。UAC永久链接。文章修改。修复了以其他用户身份启动进程的bug(中等IL vs. 高IL问题)。
© . All rights reserved.