开发人员 IE 受保护模式生存指南






4.91/5 (49投票s)
2007年5月21日
19分钟阅读

454640

1922
功能损坏?API 失败?使用此指南让您的 IE 插件在受保护模式下恢复运行!
目录
引言
在2005年PDC上看到Vista和IE 7后,我 评论道,受保护模式将使合法的IE扩展(而不仅仅是间谍软件)的生活变得困难。去年,我在试用Vista Beta 2时,亲眼看到了有多困难——我的产品之一有一个IE工具栏,而受保护模式破坏了许多主要功能,因为工具栏需要与IE之外的另一个进程进行通信。经过大量的尝试和错误,以及一些不够“乖巧”的语言,我最终让这些功能重新工作起来。在付出了所有这些努力之后,我想把必要的更改总结成这本生存指南,并将所有相关信息集中提供给大家。
本文假设您熟悉C++、GUI和COM编程。示例代码是用Visual Studio 2005、WTL 7.5和Windows SDK构建的。如果您需要快速掌握WTL,可以看看我关于WTL的系列文章。WTL仅用于GUI;本文提出的概念适用于所有应用程序,并且独立于任何类库。
受保护模式简介
Internet Explorer的受保护模式是Vista中的一项新功能,也是用户帐户控制(UAC)的一部分。受保护模式旨在通过限制在IE进程中运行的代码可以影响的系统部分来保护计算机。如果恶意网页利用了IE或IE插件中的代码注入漏洞,那么该代码将无法对系统造成损害。
在深入探讨受保护模式对IE插件开发人员意味着什么之前,我们需要快速回顾一下相关的安全功能。
完整性级别和UIPI
Vista引入了一个可安全对象的名为强制完整性级别的新属性。有四个级别:
- 系统:由操作系统组件使用,应用程序不应使用。
- 高:以完全管理员权限运行的进程。
- 中:以正常方式启动的进程。
- 低:由IE和Windows Mail使用,以提供受保护模式。
Windows关于进程的信息包括其启动时的完整性级别。一旦进程启动,此级别永远不会改变,只能在创建进程时设置。进程的完整性级别有三个主要影响:
- 进程创建的任何可安全对象都将具有相同的完整性级别。
- 进程无法访问完整性级别高于其自身级别的资源。
- 进程无法向完整性级别高于其自身的进程发送窗口消息。
这不是一个完整的列表,但上面列出的三点对插件的影响最大。前两项阻止低完整性进程篡改IPC资源,如共享内存,这些资源包含敏感数据或对应用程序正常运行至关重要的数据。最后一项称为用户界面进程隔离(UIPI),旨在防止诸如Shatter攻击之类的攻击,在这种攻击中,攻击者通过发送意外的消息来诱导进程运行不受信任的代码。
虚拟化
虚拟化(在一些微软文档中也称为重定向)是一项功能,它可以在阻止进程写入注册表和文件系统的受保护区域的同时,仍允许应用程序正常运行。对于中完整性进程,受保护区域是系统关键区域,如HKLM
、system32
和Program Files
目录等。低完整性进程受到更严格的限制——它只能写入用户配置文件下的特殊低权限区域的注册表和文件系统,并且任何写入这些区域以外的尝试都会被阻止。
当进程尝试写入其无权访问的区域时,虚拟化会启动,并将写入操作重定向到当前用户配置文件下的一个目录(或注册表项),并且写入操作实际上在那里发生。然后,当应用程序稍后尝试读取该数据时,读取操作也会被重定向,以便应用程序能够看到它之前写入的数据。
虚拟化会影响IE扩展,因为它们无法再执行诸如将设置写入注册表(即使在HKCU
下)供其他进程使用之类的操作。扩展程序在写入数据文件方面也受到严格限制——只有少数IE特定的目录,如Favorites
和Cookies
是可写的。
何时启用受保护模式?
在Vista的默认配置中,IE始终在受保护模式下运行。状态栏(如下图所示)有一个指示器,显示何时启用了受保护模式。
您可以通过禁用UAC来完全关闭受保护模式,或者在IE选项对话框的“安全”选项卡中取消选中启用受保护模式。您还可以通过运行一个新的提升的IE实例来暂时绕过受保护模式,但请记住,这样做会使IE以高完整性运行,而不是像普通应用程序那样以中完整性运行。
示例应用程序和扩展
本文的示例代码结合了两个项目。第一个项目IEExtension是一个固定在IE窗口底部的工具条。
第二个项目DemoApp是一个与工具条通信的EXE。DemoApp本身并不做什么,有趣的部分在于IEExtension,它需要与EXE通信。这种通信受到受保护模式的极大影响,两个项目都演示了如何在受保护模式的限制下工作,并且仍然能够进行进程间通信。
工具条上有几个用于执行各种IPC任务的按钮。成对的按钮显示了一种在受保护模式下不再有效(按钮1)的旧技术,以及一种新的、支持受保护模式且有效(按钮2)的新技术。列表控件显示各种状态消息,例如Windows API的返回值。
文章的其余部分将重点介绍扩展程序为了在受保护模式下正常工作需要做什么。我将介绍一些API,然后展示示例扩展程序中使用的该API的代码。每个主题对应工具条上的一个按钮(或一对按钮),因此您可以在阅读文章时参考相应的代码。
在受保护模式限制下工作
IE 7有几个新的API供扩展程序使用,以执行在受保护模式下受限制的功能。这些API位于ieframe.dll中。您可以通过使用iepmapi.lib导入库直接链接到这些API,或者使用LoadLibrary()
/GetProcAddress()
在运行时获取函数指针。如果您希望您的扩展程序在Vista之前的Windows版本上加载,则必须使用后一种方法。
许多执行特权操作的功能都使用一个代理进程,即ieuser.exe。由于IE进程以低完整性运行,它无法自行执行更高特权的任务;ieuser.exe承担了这个角色。您将在本文以及微软文档中经常看到这个代理进程。
运行时检测受保护模式
为了确定我们的扩展程序是否正在受保护模式的IE进程中运行,我们使用IEIsProtectedModeProcess()
。
HRESULT IEIsProtectedModeProcess(BOOL* pbResult);
如果返回值是成功的HRESULT
并且*pbResult
是TRUE
,则表示已启用受保护模式。根据*pbResult
中返回的值,您可以在代码中根据需要采取不同的操作。
HRESULT hr; BOOL bProtectedMode = FALSE; hr = IEIsProtectedModeProcess ( &bProtectedMode ); if ( SUCCEEDED(hr) && bProtectedMode ) // IE is running in protected mode else // IE isn't running in protected mode
示例工具条扩展在启动时调用此API,并显示一条消息指示受保护模式的状态。
写入文件系统
启用受保护模式后,扩展程序只能写入用户配置文件下的几个目录。在Temp
、Temporary Internet Files
、Cookies
和Favorites
目录有一些可写入的特殊低完整性目录。IE还提供了一些兼容性shims,它们会虚拟化其他常用目录。(我还没有看到这些“常用目录”的完整列表。IEBlog上的这篇帖子提到了shim功能,但没有详细说明涵盖哪些目录。)写入这些目录的操作将被重定向到Temporary Internet Files
的一个子目录。如果扩展程序尝试写入敏感位置,如windows
目录,操作将失败。
当扩展程序想要写入文件系统时,它应该使用IEGetWriteableFolderPath()
API,而不是GetSpecialFolderPath()
、GetFolderPath()
或SHGetKnownFolderPath()
。IEGetWriteableFolderPath()
知道受保护模式,如果扩展程序请求一个它不允许写入的目录,IEGetWriteableFolderPath()
将返回E_ACCESSDENIED
。IEGetWriteableFolderPath()
的原型是:
HRESULT IEGetWriteableFolderPath(GUID clsidFolderID, LPWSTR* lppwstrPath);
GUID是这些之一,定义在knownfolders.h
中:FOLDERID_InternetCache
、FOLDERID_Cookies
、FOLDERID_History
。似乎没有Temp
目录的GUID,因此在需要写入临时文件时,我建议使用FOLDERID_InternetCache
。
这是一个在缓存中创建临时文件的代码片段:
HRESULT hr; LPWSTR pwszCacheDir = NULL; TCHAR szTempFile[MAX_PATH] = {0}; hr = IEGetWriteableFolderPath(FOLDERID_InternetCache, &pwszCacheDir); if ( SUCCEEDED(hr) ) { GetTempFileName(CW2CT(pwszCacheDir), _T("bob"), 0, szTempFile); CoTaskMemFree(pwszCacheDir); // szTempFile now has the full path to the temp file. }
如果IEGetWriteableFolderPath()
成功,它将分配一个缓冲区并将其地址返回给pwszCacheDir
。我们将该目录传递给GetTempFileName()
,然后用CoTaskMemFree()
释放缓冲区。
IEGetWriteableFolderPath()
不仅仅用于写入临时文件。当扩展程序使用受保护模式下的保存文件对话框版本时(如下面的“提示用户保存文件”部分所述),它也会使用此API。演示项目在您单击“保存日志”按钮时使用此API。
写入注册表
由于注册表是系统的重要组成部分,因此至关重要的是,在浏览器中运行的代码不能更改可能导致恶意代码运行的任何注册表部分。为此,扩展程序只能写入一个键。与文件系统一样,该键位于当前用户配置文件下的一个特殊低权限区域。要获取此键的句柄,请调用IEGetWriteableHKCU()
。
HRESULT IEGetWriteableHKCU(HKEY* phKey);
如果成功,您可以使用返回的HKEY
在其他注册表API中写入任何必要的数据。演示项目不使用注册表,但此API非常简单,使用起来应该没有问题。
提示用户保存文件
当IE在受保护模式下运行时,扩展程序仍然有办法(间接)在低权限区域之外写入文件系统。扩展程序可以通过调用IEShowSaveFileDialog()
来显示一个通用的保存文件对话框。如果用户输入文件名,扩展程序可以通过调用IESaveFile()
让IE写入文件。请注意,此操作始终会导致用户看到保存文件对话框;这确保了用户始终知道即将写入一个文件。
保存文件的步骤是:
- 调用
IEShowSaveFileDialog()
显示保存文件对话框。 - 调用
IEGetWriteableFolderPath()
获取IE缓存目录。 - 将数据写入缓存目录中的临时文件。
- 调用
IESaveFile()
将该数据复制到用户选择的文件名。 - 清理临时文件。
IEShowSaveFileDialog()
是通用文件保存对话框的包装器。
HRESULT IEShowSaveFileDialog( HWND hwnd, LPCWSTR lpwstrInitialFileName, LPCWSTR lpwstrInitialDir, LPCWSTR lpwstrFilter, LPCWSTR lpwstrDefExt, DWORD dwFilterIndex, DWORD dwFlags, LPWSTR* lppwstrDestinationFilePath, HANDLE* phState );
hwnd
是扩展程序拥有的窗口,IE将使用最顶层的所有者窗口作为对话框的父窗口。lppwstrDestinationFilePath
是指向LPWSTR
的指针,该指针设置为用户选择的文件路径。这只是信息性的,因为扩展程序无法直接写入该路径。phState
是指向HANDLE
的指针,如果用户选择了一个文件,则会填充该句柄,该句柄用于调用其他API。其他参数的使用方式与OPENFILENAME
结构中的相应成员类似。
如果用户选择了一个文件名,IEShowSaveFileDialog()
将返回S_OK
;如果用户取消对话框,则返回S_FALSE
;如果API失败,则返回失败的HRESULT
。
这是演示项目中将日志保存到文件的代码。我们首先调用IEShowSaveFileDialog()
提示用户输入文件路径。
void CBandDialog::OnSaveLog(UINT uCode, int nID, HWND hwndCtrl) { HRESULT hr; HANDLE hState; LPWSTR pwszSelectedFilename = NULL; const DWORD dwSaveFlags = OFN_ENABLESIZING | OFN_HIDEREADONLY | OFN_PATHMUSTEXIST | OFN_OVERWRITEPROMPT; // Get a filename from the user. hr = IEShowSaveFileDialog ( m_hWnd, L"Saved log.txt", NULL, L"Text files|*.txt|All files|*.*|", L"txt", 1, dwSaveFlags, &pwszSelectedFilename, &hState ); if ( S_OK != hr ) return;
接下来,我们使用IEGetWriteableFolderPath()
获取我们可以写入的缓存目录的位置。
LPWSTR pwszCacheDir = NULL; TCHAR szTempFile[MAX_PATH] = {0}; // Get the path to the IE cache dir, which is a dir that we're allowed // to write to in protected mode. hr = IEGetWriteableFolderPath ( FOLDERID_InternetCache, &pwszCacheDir ); if ( SUCCEEDED(hr) ) { // Get a temp file name in that dir. GetTempFileName ( CW2CT(pwszCacheDir), _T("bob"), 0, szTempFile ); CoTaskMemFree ( pwszCacheDir ); // Write our data to that temp file. hr = WriteLogFile ( szTempFile ); }
如果到目前为止一切都成功了,我们调用另一个受保护模式API,IESaveFile()
。IESaveFile()
接收IEShowSaveFileDialog()
返回的状态句柄和我们临时文件的路径。请注意,此HANDLE
不是标准句柄,无需关闭;在调用IESaveFile()
之后,HANDLE
会自动释放。
如果出于某种原因我们最终没有调用IESaveFile()
——例如,在写入临时文件时发生错误——我们确实需要清理HANDLE
以及IEShowSaveFileDialog()
分配的任何内部数据。我们通过调用IECancelSaveFile()
来完成此操作。
if ( SUCCEEDED(hr) ) { // If we wrote the file successfully, have IE save that data to // the path that the user chose. hr = IESaveFile ( hState, T2CW(szTempFile) ); // Clean up our temp file. DeleteFile ( szTempFile ); } else { // We couldn't complete the save operation, so cancel it. IECancelSaveFile ( hState ); }
启用扩展程序与其他应用程序之间的通信
到目前为止,我们所看到的关于文件系统和注册表的所有主题都涉及到在IE进程中运行的代码。借助虚拟化和兼容性shims的可用性,IE可以轻松地限制其自身进程中运行的代码,并防止其调用可能损坏系统的API。现在我们将看到一个更复杂的主题,它有一个更复杂的解决方案:与其他以更高完整性级别运行的进程进行IPC。我们将涵盖两种不同形式的IPC:内核对象和窗口消息。
创建IPC对象
当扩展程序和独立进程想要通信时,这种通信发生在两者之间,而无需经过IE包装器。NT安全API和强制完整性级别发挥作用,默认情况下,从扩展程序到独立应用程序的通信会被阻止,因为该应用程序以比IE更高的完整性级别运行。
如果独立应用程序创建了一个扩展程序需要使用的内核对象(例如,事件或互斥体),则在扩展程序可以访问该对象之前,应用程序必须降低该对象的完整性级别。应用程序可以使用安全API更改对象上的ACL并降低其完整性。下面的代码摘自MSDN文章“理解并使用受保护模式的Internet Explorer”,它接收一个内核对象的HANDLE
并将其完整性级别设置为低。
// The LABEL_SECURITY_INFORMATION SDDL SACL to be set for low integrity LPCWSTR LOW_INTEGRITY_SDDL_SACL_W = L"S:(ML;;NW;;;LW)"; bool SetObjectToLowIntegrity( HANDLE hObject, SE_OBJECT_TYPE type = SE_KERNEL_OBJECT) { bool bRet = false; DWORD dwErr = ERROR_SUCCESS; PSECURITY_DESCRIPTOR pSD = NULL; PACL pSacl = NULL; BOOL fSaclPresent = FALSE; BOOL fSaclDefaulted = FALSE; if ( ConvertStringSecurityDescriptorToSecurityDescriptorW ( LOW_INTEGRITY_SDDL_SACL_W, SDDL_REVISION_1, &pSD, NULL ) ) { if ( GetSecurityDescriptorSacl ( pSD, &fSaclPresent, &pSacl, &fSaclDefaulted ) ) { dwErr = SetSecurityInfo ( hObject, type, LABEL_SECURITY_INFORMATION, NULL, NULL, NULL, pSacl ); bRet = (ERROR_SUCCESS == dwErr); } LocalFree ( pSD ); } return bRet; }
示例代码使用两个互斥体,其目的是允许扩展程序知道应用程序是否正在运行。DemoApp EXE在进程启动时创建它们,并且当您单击“打开互斥体”按钮之一时,扩展程序会尝试打开它们。互斥体1具有默认的完整性级别,而互斥体2使用上面列出的SetObjectToLowIntegrity()
函数设置为低完整性。这意味着当受保护模式开启时,扩展程序只能访问互斥体2。当您单击两个“打开互斥体”按钮时,您将看到以下结果:
受保护模式的另一个影响是,扩展程序不能让一个独立应用程序继承内核对象句柄。例如,当启用受保护模式时,我们的扩展程序无法创建文件映射对象,运行独立应用程序(在调用CreateProcess()
时为bInheritHandles
参数传递TRUE
),并让该应用程序继承文件映射对象上的句柄。
HANDLE hMapping; SECURITY_ATTRIBUTES sa = { sizeof(SECURITY_ATTRIBUTES) }; sa.bInheritHandle = TRUE; hMapping = CreateFileMapping( INVALID_HANDLE_VALUE, &sa, PAGE_READWRITE, 0, cbyData, NULL ); // Omitted: Put data in the shared memory block... // Run the EXE and pass it the shared memory handle. CString sCommandLine; BOOL bSuccess; STARTUPINFO si = { sizeof(STARTUPINFO) }; PROCESS_INFORMATION pi = {0}; sCommandLine.Format( _T("\"C:\\path\\to\\DemoApp.exe\" /h:%p"), hMapping ); bSuccess = CreateProcess( NULL, sCommandLine.GetBuffer(0), NULL, NULL, TRUE, // TRUE => the new process should inherit handles NORMAL_PRIORITY_CLASS, NULL, NULL, &si, &pi );
然后DemoApp将从/h
开关读取句柄值,并使用该值调用MapViewOfFile()
并读取数据。这是一种使新进程自动接收内核对象句柄的标准技术,但在启用受保护模式时,新进程实际上是由代理进程启动的。由于IE进程不直接启动新进程,因此句柄继承不起作用。
为了绕过此限制,扩展程序可以使用预定义的IPC对象名称,并且独立应用程序可以访问该对象,因为该对象将具有低完整性。如果您不想使用预定义的名称,可以在运行时生成一个名称(例如,使用GUID作为名称)并将其传递给独立应用程序。
// Get a GUID that we'll use as the name of the shared memory object. GUID guid = {0}; WCHAR wszGuid[64] = {0}; HRESULT hr; CoCreateGuid( &guid ); StringFromGUID2( guid, wszGuid, _countof(wszGuid) ); // Create the file mapping object, we don't need a SECURITY_ATTRIBUTES // struct since the handle won't be inherited. HANDLE hMapping; hMapping = CreateFileMapping( INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, cbyData, CW2CT(wszGuid) ); // Omitted: Put data in the shared memory block... // Run the EXE and pass it the name of the shared memory object. // Note that the bInheritHandles param to CreateProcess() is FALSE. CString sCommandLine; BOOL bSuccess; STARTUPINFO si = { sizeof(STARTUPINFO) }; PROCESS_INFORMATION pi = {0}; sCommandLine.Format( _T("\"C:\\path\\to\\DemoApp.exe\" /n:%ls"), wszGuid ); bSuccess = CreateProcess( NULL, sCommandLine.GetBuffer(0), NULL, NULL, FALSE, // FALSE => the new process does not inherit handles NORMAL_PRIORITY_CLASS, NULL, NULL, &si, &pi );
使用此方法,EXE在命令行中接收IPC对象的名称。然后它可以调用OpenFileMapping()
来访问该对象。但是,此方法有一个复杂之处,即您需要注意对象生命周期管理。在使用句柄继承时,事件顺序是:
- 扩展程序创建IPC对象,并将其引用计数设置为1。
- 扩展程序启动新进程,该进程继承句柄。这将使对象的引用计数增加到2。
- 扩展程序可以立即关闭其句柄,因为它不再需要该对象。引用计数降至1。
- 新进程使用IPC对象执行其所需的操作。由于它仍然有一个打开的句柄,对象将保留,直到新进程关闭其句柄。
如果我们使用上述步骤来传递对象的名称给EXE,我们将创建一个竞态条件,即在EXE有机会打开句柄之前,扩展程序可能会关闭其句柄(从而删除IPC对象)。
- 扩展程序创建IPC对象,并将其引用计数设置为1。
- 扩展程序启动新进程,并向其传递IPC对象的名称。引用计数仍为1。
- 扩展程序不能立即关闭其句柄,它需要等待新进程打开该对象的句柄。这需要某种形式的同步。
- 新进程打开对象句柄并读取数据。此时,它可以向扩展程序发出信号以唤醒扩展程序的线程。现在扩展程序可以安全地关闭其句柄了。
我在示例项目中选择的做法是让DemoApp在创建主对话框之前从共享内存中读取数据。然后,扩展程序在CreateProcess()
之后调用WaitForInputIdle()
,这会使线程阻塞,直到DemoApp的主对话框被创建并显示。一旦DemoApp的线程空闲,它就完成了对共享内存的使用,此时扩展程序可以安全地关闭其句柄。
该工具条演示了通过共享内存传递数据的两种方法。当您单击“运行EXE 1”按钮时,工具条将当前日期和时间写入共享内存,并将句柄传递给DemoApp。启用受保护模式后,此方法将失败,DemoApp将报告无效句柄错误。单击“运行EXE 2”会将文件映射对象名称传递给DemoApp,然后DemoApp将显示它从共享内存中读取的数据。
接收窗口消息
UIPI阻止某些窗口消息(以及所有值大于或等于WM_USER
的消息)从低完整性进程发送到高完整性进程。如果您的应用程序需要接收来自扩展程序的消息,您可以调用ChangeWindowMessageFilter()
来允许一条特定消息通过。
BOOL ChangeWindowMessageFilter(UINT message, DWORD dwFlag);
message
是消息的值,dwFlag
指示消息是应该被允许还是被阻止。传递MSGFLT_ADD
以允许消息,或传递MSGFLT_REMOVE
以阻止消息。在处理来自其他进程的窗口消息时要非常小心——如果您通过消息接收数据,您必须将其视为不受信任的,并且在采取行动之前应该对其进行验证。(请记住,进程间消息可以从任何地方发送,并且这些消息可能被用于攻击,如前所述。)
演示项目展示了如何通过注册的窗口消息进行通信。与互斥体示例一样,有两种消息。DemoApp在其OnInitDialog()
中的代码允许第二条消息通过过滤器:
m_uRegisteredMsg1 = RegisterWindowMessage( REGISTERED_MSG1_NAME ); m_uRegisteredMsg2 = RegisterWindowMessage( REGISTERED_MSG2_NAME ); ChangeWindowMessageFilter( m_uRegisteredMsg2, MSGFLT_ADD );
当您单击工具条上的两个“发送消息”按钮时,您将看到以下结果:
第一条消息不允许通过过滤器,并且SendMessage()
返回0。
受保护模式下的其他限制
运行其他应用程序
IE还有另一种机制可以防止恶意代码与其他进程通信或启动其他进程。如果扩展程序尝试启动另一个进程,IE将在启动进程之前向用户请求权限。例如,使用“查看源代码”命令会显示此提示:
如果您的扩展程序需要运行一个独立的EXE,您可以添加一个注册表项来告诉IE您的EXE是受信任的,可以无提示地运行。控制此行为的注册表项是HKLM\Software\Microsoft\Internet Explorer\Low Rights\ElevationPolicy
。创建一个新的GUID,然后在ElevationPolicy
下创建一个名为该GUID的键。在新键中,创建三个值:
AppName
:可执行文件的文件名,例如“DempApp.exe”。AppPath
:EXE所在的目录。Policy
:一个DWORD
值,设置为3。
如果您的安装程序没有创建这样的项,如果您选中了不再为此程序显示警告复选框,IE本身会创建一个。
拖放内容到其他应用程序
如果您尝试从网页拖放内容到另一个应用程序,也会显示类似的提示:
可以通过注册表项来抑制此提示。格式与上面描述的相同,但您的应用程序的项应该放在DragDrop
下,而不是ElevationPolicy
下。
DemoApp注册为一个放置目标,如果您在IE中选择文本并将其拖入DemoApp对话框,它将显示一条消息,表明它收到了拖放操作。
进一步阅读
IEBlog:Vista IE7中的受保护模式
版权和许可
本文为版权材料,©2007 Michael Dunn,保留所有权利。我知道这无法阻止人们在网络上随意复制它,但我还是必须这么说。如果您有兴趣翻译本文,请给我发邮件告知。我预计不会拒绝任何人翻译的许可,只是想知道翻译情况,以便在此处发布链接。
本文附带的演示代码已发布到公共领域。我以这种方式发布它,是为了让代码能够造福所有人。(我不会将文章本身发布到公共领域,因为只在CodeProject上提供文章有助于我自身的可见性和CodeProject网站。)如果您在自己的应用程序中使用演示代码,发邮件告知我将不胜感激(仅为满足我是否有人从我的代码中受益的好奇心),但并非必需。在您自己的源代码中注明出处也表示赞赏,但并非必需。
修订历史
2007年5月21日:文章首次发布。
2007年5月24日:添加了许可部分。修复了示例代码中的IEExtension项目,它缺少IDL文件。