处理线程内部通讯议题
尽管主要线程执行了收到动作要求的HandlerEx函数,一个服务还是很难编写的,通常服务线程需要去做现实的工作以处理要求。例如,您也许想编写一个处理从命名管道上传来之客户端要求的服务。您的服务线程会自己暂停以等待一个客户端连结。如果您的HandlerEx线程收到一个SERVICE_CONTROL_STOP控制码,您该如何停止该服务?我曾经看过许多开发者只是简单地从HandlerEx函数中呼叫TerminateThread来强迫删除该服务线程。现在,您应该知道TerminateThread是您可能呼叫的函数中最糟的一个,因为线程不能取得一个清除的机会:线程的堆叠没有被删除、线程无法释放任何必须被等待的核心物件、DLLs没有收到线程已被删除的通知等等。
使服务停止之适当方法是以某种方式醒来、察看哪个服务应被停止、正确地清除以及从它的ServiceMain函数回传。为了建立一个服务执行它,您必须实作一些在您的HandlerEx与您的ServiceMain函数间的内部线程通讯。您可以使用任何您喜欢的要求内部线程通讯机制,包括APC伫列、套接字以及视窗讯息。我总是使用I/O完成端口。
为了更新目前的状态,一个服务必须频繁地呼叫SetServiceStatus。所有的这一切状态回报在服务编码方面可能是很困难的。服务的实作通当会思考在哪里放置呼叫SetServiceStatus的函数。以下是一些可能的方式:
- 使HandlerEx函数建立一个初始呼叫SetServiceStatus以回报未完成的动作,然后使用线程内部通讯去取得ServiceMain线程的控制码。ServiceMain线程会做该工作并且使用线程内部通讯以使HandlerEx函数得知该动作已完成。由此得知,HandlerEx再次呼叫SetServiceStatus以回报服务的新执行状态。
- 使HandlerEx函数使用线程内部通讯以取得ServiceMain线程的控制码。ServiceMain线程会建立初始的呼叫SetServiceStatus以回报未完成的动作、执行这个工作,然后再次呼叫SetServiceStatus以回执服务的新执行状态。
- 使HandlerEx函数建立初始呼叫至SetServiceStatus以回报未完成动作,然后使用线程内部通讯去取得ServiceMain线程的控制码。ServiceMain线程会执行这个工作而且也会再次呼叫SetServiceStatus以回报服务的新执行状态。
所有以上所述的情形有正面的也有反面的。我曾对这些可能的方案做过实验并且大胆的建议使用最后的选项,理由是:
第一,SCP呼叫一个函数以控制一个服务,而且SCM传递此控制给服务。由于这点,SCP会暂停执行,等待服务呼叫SetServiceStatus以指示服务已经接收该控制码。假如服务的HandlerEx函数没有在30秒内回传,SCM会允许SCP醒来并且SCP的函数会呼叫去控制服务回传失败。
第二,HandlerEx函数经由服务处理程序的主要线程而被执行(所有在一个单一处理程序中的服务皆拥有它们的HandlerEx函数,而且它们被主要线程执行)。如果HandlerEx在等候ServiceMain于回传之前完成该动作,任何在同一个处理程序中的其他服务皆不能接收动作要求或通知。这可以使所有其他的服务出现没有回应的情形,这是不被接受的。
所以我优先选择第叁种方法—HandlerEx函数建立初始呼叫至SetServiceStatus,线程内部通讯被用来取得ServiceMain线程的控制码,而且由ServiceMain线程完成工作与呼叫SetServiceStatus以回报新的执行状态。然而,这个方法也有一个问题:存在着一个潜在的?赛条件(race condition)。想像一个服务的HandlerEx函数接收了一个SERVICE_CONTROL_PAUSE控制码,并以SERVICE_PAUSE_PENDING回答,然后传递控制码至ServiceMain线程。当ServiceMain线程开始处理该控制码时,突然间,HandlerEx线程先取得ServiceMain线程并且接收一个SERVICE_CONTROL_STOP控制码。HandlerEx函数现在回应一个SERVICE_STOP_PENDING控制码并且新的控制码伫列至ServiceMain线程中。当ServiceMain绪行绪再次取得CPU时间,它会完成自己的SERVICE_CONTROL_PAUSE控制码过程并且回报SERVICE_PAUSED。然后线程会察看被伫列之SERVICE_CONTROL_STOP控制码、停止服务以及回报SERVICE_STOPPED。在这些以后,SCM会接收以下的更新状态:
SERVICE_PAUSE_PENDING
SERVICE_STOP_PENDING
SERVICE_PAUSED
SERVICE_STOPPED
就像您看到的,这些更新毫无意义,只会让管理者相当困惑。请注意,不管怎样,服务还是执行得很好。您会对我曾经见过可以实际地回执这个序列的数量感到惊讶。这些服务的开发者从未修复这些问题,它是不太可能会发生的,因为一个管理者会快速地发布动作要求至服务中—但是它还是可能发生。为了解决这个序列问题您必须使用一个同步线程机制。在本章后面的TimeService应用程序范例中使用了一个CGAte的C++ 类别来有效地解决这个问题。
当我开始使用服务时,认为SCM可能是预防发生竞赛条件的原因。但是经验告诉我SCM对确定一个适当地接受控制码的服务来说是没有帮助的。事实上,它真的没有什么帮助。意思是说:当一个服务已被暂停时,试着传送给服务一个SERVICE_CONTROL_PAUSE控制码。因为嵌入式管理单元一旦知道服务已被暂停即会使暂停按钮失效,所您不能在服务嵌入式管理单元中使用它。但是如果您使用SC.exe命令列工具程序,任何传送一个暂停控制码至已经被暂停的服务并不会被停止。我曾预期SCM回报失败至SC.exe工具程序中,但是SCM只会呼叫服务的HandlerEx函数,并传送SERVICE_CONTROL_PAUSE控制码。您的服务必须能够小心地处理这些不正确的控制码。
我曾经见过许多没有对存在于一个资料列中被多次传送至服务之相同控制码的可能性做处理。例如,我知道当服务被闲置时,它关闭了命名管道的handle。这个服务接下来会建立另一个核心物件,碰巧的是,它取得了与原始命名管道同样的handle值。然后服务会接收另一个暂停控制码并且呼叫CloseHandle与传递旧管道的handle值。由于这个值刚好和另一个核心物件的handle相同,所以新的核心物件会被删除,而其馀的服务则由于奇怪且不可思议的方法而失败。我无法告诉您该如何愉快的调整这个混乱的情形。
为了要修复这个多重的停止、暂停或继续执行控制码的问题,第一件是即是察看您的哪一些服务已经位于需求状态。如果它是的话,不要呼叫SetServiceStatus,也不要执行您的改变状态之程序代码—只要回传即可。这里有一些我常用在服务的逻辑。当HandlerEx函数接收一个SERVICE_CONTROL_PAUSE控制码时,HandlerEx函数会呼叫SetServiceStatus以回报SERVICE_PAUSE_ PENDING,呼叫SuspendThread以使服务的线程暂停执行,然后再次呼叫SetServiceStatus以回报SERVICE_PAUSED控制码。这一系列的呼叫是为了避免竞赛条件的产生,因为所有的工作被一个线程完成,但是这个控制码做了什么?闲置服务线程会使该服务暂停执行吗?对于这些,我想必须回答「是的」。然而,对于服务来说,暂停它代表什么意思?答案是依据服务而定。
如果我正在编写一个处理网路上之客户端要求的服务,对我来说,暂停代表停止接受新的要求,但是要如何处理正在处理中的要求呢?也许我应该完成它以使客户端不会被无限期地悬置。如果我的HandlerEx函数简单地呼叫了SuspendThread,该服务线程可能会在任何的状态中。也许该线程正呼叫至malloc,并试着去配置一些内存。假如另一个服务在也呼叫malloc的同一个处理程序中执行,这个服务也会被悬置(直到该存取动作被序列化为止)。这必定是我们不想要产生的情形。
看看这个如何:您认为您应该被允许去停止的一个已被暂停的服务吗?我想是的,而且显然Microsoft也是这样想的,因为即使该服务已被暂停,服务嵌入式管理单元还是允许我去按下停止按钮来停止它的执行。但是我要如何停止一个因为它的线程已被悬置之已被暂停的服务呢?请不要回答TerminateThread。
这些是关于向建立服务开发挑战的议题。
关于服务的议题
当您第一次开发服务程序时,您将会注意到有一些程序并没有照您的预期的状况执行。服务是一个在一个特殊的作业环境中执行的野兽。本节将会讨论一些您可能会遭遇的情形。然而,并不会花太多时间在它们上面,因为本书的各个章节中会对它们做更详细的说明。在此只是先给您一个大略的概念而已。
本机帐户与特定的使用者帐户
本节会开始解释在本机帐户中与在一个特定的使用者帐户中执行服务有什么不同。本机帐户是一个被作业系统给予的帐户,作业系统不会对它限制资源的存取权限。在本机帐户中执行一个服务时,可以存取任何的目录或文件、改变系统时间、启动或停止任何的服务、关闭机器以及没有任何的障碍即可以执行所有其他被限制的正常动作。一个本机服务被认为是系统之Trusted Computing Base(TCB)的一部份。
说明
这里是我在http://nsi.org/Library/Compsec/compglos.txt找到对Trusted Computing Base(TCB)的定义内容:「一个电脑系统中的总体保护机制」—包括了硬体、韧体以及软件—负责加强一个安全策略的结合。一个TCB由一个或多个在一个产品或系统上实施统一的安全策略之元件所组成。一个TCB的能力为正确地执行那些依据TCB以及由系统管理者所输入与安全策略有关之正确参数的安全策略。(例如,清除一个使用者帐户)。」
当然,所有的核心模式程序代码—硬体设备、内存管理、文件系统、安全控管、线程的工作排程等等,皆是系统之TCB的一部份。在TCB中执行的服务具有非常大的权力,这就是为什么只有机器的管理者拥有安装服务权利的缘故。
所以如果本机帐户拥有很大的权力,为何您还会想让服务在一个特定的使用者帐户中执行呢?的确,本机服务拥有在本机上的所有权力,在预设的情形下,它们不能在整个网路中被使用。例如,因为本地端机器的本机帐户无法被远端机器验证,所以本机服务不能存取另一台机器上被分享的目录、文件或打印机。在Windows 2000中,Microsoft对这个情况做了改善:当一台电脑位于网域中,您可以将它视为一个使用者帐户并取得它的存取权限。
如果您正在执行服务的机器并不存在一个网域中,而且您的服务需要存取网路资源时,以下是您可以做的:
- 在一个已取得网路资源存取权之特定使用者帐户下执行服务。注意如此做将会限制该服务所能在本机上做的工作。
- 使用一个不要求验证的通讯协定来存取资源。例如,一个本机服务可以经由套接字、命名管道或邮件通讯协定。当然,要使这个通讯执行成功需要远端机器支援这些通讯协定。这些连结被称为NULL工作阶段,并且可以经由设定位于以下所列之登录机码中的NullSessionPipes与NullSessionShares的值以加以控制:
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services
\LanmanServer\Parameters您也能够允许由所有NULL工作阶段的连结而把资料值设置成0(位于相同的子机码下)以存取机器上的所有管道与被分享的资料。虽然您可以这么做,但您却不应该做它,因为它会使系统上产生一个巨大的安全漏洞。
- 模拟一个特定使用者以存取资源。为了达到这个目的,您可以呼叫许多Windows所提供的模拟函数(我们将在本书第四篇的章节中讨论模拟的部份)。一个服务也能经由使本机服务呼叫LogonUser函数来模拟一个特定的使用者,并传递一个网域、使用者名称与密码以被验证。注意LogonUser函数需要经过TCB授权(也被视为作业系统特权的一部份),在预设的情形下,只有本机帐户—多么方便啊!
本机与特定使用者之登录子机码
登录被分为二个主要的机码。第一个为HKEY_LOCAL_MACHINE,它被用来储存所有的系统设定。一个服务或是一个应用程序可以经常读取任何位于此机码中的设定值。
第二个机码为HKEY_USERS,它被用来储存每一个使用者之特定设定值。这个机码会进一步分成二个类型之子机码。第一个类型是一个特定的使用者子机码。机器上的每一个使用者帐户皆有一个在HKEY_USERS之下对应至子机码之登录设定集合。当特定的使用者登入并且成为一个互动的使用者时,常见的HKEY_CURRENT_USER机码会对应至位于HKEY_USERS下的特定使用者之子机码。
第二个位于HKEY_USERS中的子机码称为 .DEFAULT,它包含了一个使用者的内定设定值。当一个新的使用者帐户在系统中被建立时,在HKEY_USERS之中也会建立一个新的子机码而且该子机码的设定值会与目前存放在 .DEFAULT子机码中的设定相同。
就像位于HKEY_LOCAL_MACHINE中的设定一般,虽然一个服务并不需要存取它,但是存在HKEY_USERS\.DEFAULT中的设定值还是可以被服务与应用程序使用。一个存放于HKEY_USERS下之特定的使用者设定不能被使用,除非该使用者登入至系统中。由于服务通常会在本机帐户下执行,所以服务不该试图去存取任何储存在HKEY_USERS中特定使用者帐户内容。相同的一个本机帐户服务也不该使用HKEY_CURRENT_USER来存取登录之内容。关于登录设定与使用者设定档的更多资讯,请参阅
第五章 。核心物件之安全性
本节会介绍许多开发者皆会遇到的一个常见问题。即当他们的客户端应用程序与服务在同一台机器上执行而且客户端与服务尝试分享一个核心物件时所发生的问题。
以下为解决方案。您的服务开始执行并且呼叫CreateFileMapping去建立一个文件对应的物件。CreateFileMapping建立了一个核心物件,所以它的参数之一是一个SECURITY_ATTRIBUTES资料结构的位址。假如您像多数的程序开发者一样的话,则您会传递NULL给此参数,因为核心物件会被以「预设的安全性」建立。请注意我所提的是「预设的安全性」,而不是「没有安全性」。预设的安全性表示在物件被建立时经由安全性条件而定义之物件存取控制。
例如,一个核心物件会被本机服务所建立,在预设的情形下,它被允许可以完全的存取任何不在执行中的本机帐户,并只允许可以读取与执行本端管理者之成员群组。所以如没有一个本机服务以预设的安全性建立了一个文件对应的物件,则一个在本机管理员帐户下执行的应用程序可以从读取文件对应物件,但不能以任何方式对它做写入的动作。在任何其他帐户下执行的应用程序不能完全存取文件对应物件。
现在,我只是想要使您知道这个议题。有很多关于客户端与服务之核心物件安全性的内容可以谈,而且有很多方法可以处理它,但是必须是在您更了解Windows安全性的前提下。所以建议您阅读本书第五篇中有关安全性的章节,以取得所有的细节。
互动式的服务、视窗配置与桌面
本节只讨论有关服务如何影响视窗配置与桌面之内部操作的议题。有关视窗配置与桌面的更多资讯,请参阅 第十章 。
当您建立了一个核心物件时,您可以指定它应该被安全地经由一个SECURITY_ATTRIBUTES结构传送。但是像视窗与功能表这种使用者物件要如何做呢?使用者物件使用了一个不同的型式;它们不会被开启或关闭—只要在您需要的时候存取它们即可,如此可使程序代码容易编写以及增进效能。另外,使用存在于16位元之Windows作业系统中的物件并不以任何形式来支援安全性。如果Microsoft加入SECURITY_ATTRIBUTES结构至CreateWindow以及CreateMenu中,开发者在放置他们的16位元程序代码时会产生困难。
Microsoft需要一个在不影响已存在之函数以及不影响您使用物件的前提下,让使用者物件变得安全的方式。这包括了所有关于视窗配置与桌面的部份。一个视窗配置为一个 逻辑 的键盘、滑鼠以及显示器之集合体。「逻辑」一字表示设备不一定真的存在。当系统被启动时,它会建立互动式的视窗配置,称为「WinSta0」;实体的键盘、滑鼠以及显示器会与这个视窗配置关联。一个视窗配置也可以包含它所拥有的记事本、一个通用元素的集合以及一个桌面物件群组。
一个桌面由一个逻辑的显示外观以及一个使用者物件的集合所组成:视窗、功能表以及拦截程序(Hook)。线程也会与桌面联系(请参阅Platform SDK文件中的SetThreadDesktop与GetThreadDesktop函数的内容)。假如一个已与桌面关联的线程试图传送一个讯息至被另一个桌面建立的视窗时,线程不能在属于另一个桌面之线程上安装拦截程序。
就像视窗配置一样,桌面会被它的字串名称所确认。WinLogon.exe会建立叁个桌面:
- WinLogon 事先配置登入对话方块。在使用者登入后,WinLogon.exe会转换至预设桌面。
- Default Explorer.exe与所有使用者的应用程序所显示之视窗的位置。每当一个应用程序执行时,它会在桌面执行。
- Screen saver 当使用者闲置了一段时间后,执行系统的萤幕保护程序。
系统自己拥有本身的特定使用者帐户。所以实际上有二个「使用者」曾经存取机器:即本机使用者及已登入的使用者。当然,如果这两个使用者正在单一机器上执行应用程序,您不用等待所有应用程序之视窗在单一的显示设备中显现,因为二个使用者皆会要求分配一个属于自己的视窗配置。
由于这个原因,本机帐户会被给予一个属于它的视窗配置,称为「Service-0x0-3e7$」(那些数字是服务的登入SID),以及属于它的桌面,称为Default。视窗配置为非互动式而且没有一个实体的键盘、滑鼠与显示器—即本机「使用者」不是一个真实的人类,因此不需要去打字、按下滑鼠或「察看」任何东西。任何正在此视窗配置下之桌面上执行的应用程序皆可以建立一个视窗,但是该视窗不会被显示给已登录的使用者看。这就是为什么服务不应显示一个使用者介面的原因:没有人会看到它,而且线程会为了等待输入而被悬置,因此可能永远都无法被执行。
使用服务嵌入式管理单元,您可以显示一个服务的内容。您可以回想一下登入身份页签内容包含了一个允许服务与桌面互动的核取方块。当它被选择时,这个选项会让SCM以互动式视窗配置之预设桌面的方式启动一个服务:「WinSta0\Default」。注意您只能在您的服务执行于本机帐户的情形下才能选择该选项。因为本机帐户拥有较高的存取权限并且它能够存取互动式的使用者视窗配置与桌面。
有一个与服务跟桌面互动有关的问题即是预设值。桌面不会经常被显示。当萤幕保护程序执行或是使用者登出系统时,服务的使用者介面还保留在预设的桌面上,但是在下一个使用者登入前,该使用者介面不会被显示。另一个问题是它使系统相当的不安全。例如,一个标准的应用程序可以传送视窗讯息至服务的视窗中。该应用程序使用已登入使用者的安全条件执行,但是此应用程序现在正与一个经由一个不安全通道执行本机帐户之处理程序互相通讯。一个被限制的使用者可以轻易地存取不应被他们取得的系统资源。
这里有一个范例:一个服务正以本机帐户执行而且它正显示在互动式预设桌面的视窗中。正常的情形下,当已登入的使用者试图使用工作管理员的处理程序页签去删除一个服务时,会出现一个拒绝存取或类似的讯息而且该服务会继续执行。这个功能当然是被需要的。您不会想要让一个以Guest帐户登入至系统上的使用者删除一个服务器的服务、妨碍位于网路上的其他使用者存取目录、文件与打印机(特别是当服务器服务在Service.exe中执行时)。然而,假如本机服务建立了一个互动式的视窗,则已登入的使用者会在工作管理员的应用程序页签中看见它。此时选择工作结束会传送一个WM_CLOSE讯息至视窗中,使得一个服务能被删除。
说明
由于上述的所有理由,Microsoft强烈地建议使用允许服务与桌面互动核取方块。事实上,一个管理员可以经由与桌面互动的方式将以下所示之登录子机码NoInteractiveServices值设定为非0值以禁止服务执行:
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Windows
所以在一个特定的使用者帐户下执行服务如何?当SCM在一个特定的使用者帐户下执行一个可执行的服务时,SCM会先对使用者做验证,请使用者提供使用者名称与密码会成为服务设定资讯的一部份(使用者名称被储存在登录中,而密码则储存在一个安全的LSA文件中)。这个验证过程建立了一个登入工作阶段,表示哪一个取得自己拥有之非互动式视窗与桌面。可执行服务现在已包含了使用这个专用的视窗配置与桌面,其名称大概是「UserAccountLogonSID\ Default」,「UserAccountLogonSID」是一个在验证期间产生的唯一数字。
这个唯一的鉴定表示如果您拥有二个或多个设定在同一个使用者帐户下执行的服务,则每一个皆会取得它自己所拥有的登入SID、视窗配置以及桌面(如果它们在不同的处理程序中)—位于这些可执行服务中的线程不能经由使用者物件而互相通讯。这个唯一的鉴定也表示如果一个正在目前已使用互动式登入的使用者帐户下执行服务,则该服务的使用者介面不会被显示出来。反之,本机帐户只会被验证一次,所以所有分享同一个视窗配置与桌面以及可以经由使用者物件通讯的许多可执行服务会在本机帐户下执行。
Microsoft已经加入一些特定的服务特性至常见的MessageBox(Ex)函数中。第一,当您传递了MB_SERVICE_NOTIFICATION标记时,不管哪一个桌面为WinLogon、预设或是正在执行萤幕保护程序,该函数皆会在互动式视窗配置之活动中桌面上显示讯息方块。这保证讯息方块会显示在显示设备中。
第二,MessageBox(Ex)支援MB_DEFAULT_DESKTOP_ONLY标记。除了它只在互动式视窗配置的预设桌面上显示讯息方块外,此标记与MB_SERVICE_ NOTIFICATION是很类似的:一个使用者必须登入才能看见该讯息方块。注意除非一个使用者已经看见讯息方块并且已按下按钮使其离开,否则MessageBox(Ex)不会返回。顺便一提,若要使一个服务不需在本机帐户下执行,也没有核取允许服务与桌面互动之核取方块时,不是使用MB_DEFAULT_DESKTOP_ONLY就是使用MB_SERVICE_NOTIFICATION标记来达到这个目的。系统会保证该讯息方块会被显示。
如果您一直谨慎地跟随着我们的进度,则您应该会注意到在我使用为了创造互动式服务时所建立的每个方法时,使您的意志受挫了。如果您想制作一个提供使用者介面的服务时,应该如何做呢?这个回答很简单:建立一个分开的、使用一些IPC机制(RPC、COM、命名管道、套接字、内存映对文件等)与服务对谈的客户端应用程序。我知道有许多人不想这么做,因为他们必须建立一个可以自我执行的全新专案,但是建立一个分开的应用程序是正确且可得到最多支援的方法。如果您这么做的话,那么Microsoft便无法简单地打破您的架构。