CLR 托管 - 自定义 CLR - 第二部分






4.86/5 (15投票s)
NET 应用程序由 .NET 运行时运行。存在一个非托管 API,允许您在自定义运行时下运行应用程序。此 API 可让您加强安全性、提供不同的部署策略、添加框架日志记录、提供自己的内存管理实现以及沙盒化应用程序。
引言
这是对 CLR 宿主 - 自定义 CLR 的后续文章。在本文中,我们将创建一些示例自定义,修改程序集加载、加强安全性、记录异常以及替换默认内存处理程序。
背景
当我开始尝试这些接口时,主要目标是找到帮助我测试和调试托管应用程序的方法。
有时我会直接在源代码中插入检测代码,也就是旧的 printf
方法,但现在我通常使用 Trace 调用。缺点是我必须在完成后将其删除。更麻烦的是,一些程序集没有源代码。即使是内部程序集也可能成为问题。在我的项目中,我们包含由另一个部门预编译的程序集,缺点是我缺乏对其源代码树的读取权限。
我使用的另一种方法是在调试器中查看程序,最好是 Windbg 配合一些托管扩展,例如 Sos 或 Sosex。它的缺点是耗时,并且有时很难知道何时中断执行以及在哪里查找。
CLR 自定义方法吸引我的地方在于,被测应用程序 (AUT) 不会意识到它正在被监视。它不需要代码修改,并且可以开箱即用地适用于所有应用程序。
定制化
在本文中,我们将看到如何构建沙盒、记录异常、更改程序集加载以及替换内存管理器。仍有许多自定义接口可供探索,但这些超出了本文的范围。
AppSandboxer.exe
沙盒涉及两个相关概念。
隔离性
隔离对于限制应用程序的副作用很有趣。沙盒内发生的一切都是本地的,不会影响系统的其余部分。例如,它可以用于一次又一次地重试试用软件( " />),或者在全新环境中重新运行测试,只需删除旧环境并创建一个新环境。这类似于使用虚拟机时使用的撤销磁盘。
限制运行时权限
为了实现隔离,与其在沙盒内创建系统的完整副本,不如可以移除权限,从而限制副作用。如果您下载了一个程序,并且来源未知,那么运行它可能不完全安全。如果您有防火墙,您应该能够为应用程序移除互联网访问权限。如果我们下载了一个只应显示时间的应用程序,我们可能会有兴趣移除 IO 权限,但允许它访问互联网进行时间同步。我们移除的权限越多,传播到系统其余部分的副作用就越少。
安全管理器和权限集
在 CLR 主机接口的早期版本中,有一个 IHostSecurityManager
,其中包含 ResolvePolicy
、ProvideAssemblyEvidence
、ProvideAppDomainEvidence
和 DetermineApplicationTrust
等方法。我做了一些小的自定义,但这个接口的用处有限。与 AppDomains 相关的策略已被弃用,向证据添加区域信息以加强安全性无效。它已被 PemissionSet
的使用所取代。
加强安全性的最简单方法是在 AppDomainManager
本身中进行。当调用 CreateAppDomain
方法时,您只需创建一个 PermissionSet
对象,然后添加或删除权限。然后,在创建 AppDomain
时将此权限集作为参数传递。
实现
我们的沙盒将允许执行、IO 读取权限(当前目录)以及与控制台交互。代码基于上一篇文章,我们在其中展示了启动 AppDomainManagers 的样板代码。下面我只列出 essential 代码。完整源代码可在附件源代码中找到。
public sealed class AppDomainManagerSandboxer : AppDomainManager, IAppDomainManager
{
public void Run(string assemblyFilename, string friendlyName)
{
var appDomainInfo = new AppDomainSetup();
appDomainInfo.ApplicationBase = new System.IO.FileInfo(assemblyFilename).DirectoryName;
// Start with no permissions
PermissionSet permSet = new PermissionSet(PermissionState.None);
// Allow it to run
permSet.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));
// Allow interaction with console
permSet.AddPermission(new UIPermission(PermissionState.Unrestricted));
// Allow reading the current dir
permSet.AddPermission(new FileIOPermission(FileIOPermissionAccess.PathDiscovery,
AccessControlActions.View, appDomainInfo.ApplicationBase));
permSet.AddPermission(new FileIOPermission(FileIOPermissionAccess.Read,
AccessControlActions.View, appDomainInfo.ApplicationBase));
// Strong name lists.
// Contains assemblies that are considered safe, that run with full trust
var strongNames = new StrongName[0];
ad = AppDomain.CreateDomain(friendlyName, AppDomain.CurrentDomain.Evidence,
appDomainInfo, permSet, strongNames);
int exitCode = ad.ExecuteAssembly(assemblyFilename);
AppDomain.Unload(ad);
}
}
运行示例
首先正常执行 SampleApp6.exe,然后在我们的沙盒中执行,移除 IO 权限。所有异常都详细记录到 OutputDebug。我建议使用 DebugView.exe 等程序查看这些类型的日志。
AppRedirector.exe
是否想过为什么 Web 应用程序从 bin 目录加载程序集而不是从根目录加载?我们也可以通过操作 PrivateBinPathProbe
和 PrivateBinPath
来模拟这种行为。
实现
public sealed class AppDomainManagerRedirector : AppDomainManager, IAppDomainManager
{
public void Run(string assemblyFilename, string friendlyName)
{
var appDomainInfo = new AppDomainSetup();
// Prevent loading from current dir
appDomainInfo.PrivateBinPathProbe = "*";
// Set base dir. Mandatory if PrivateBinPath is used
var baseDir = System.IO.Path.GetDirectoryName(assemblyFilename);
appDomainInfo.ApplicationBase = baseDir;
// Load assemblies from plugins and bin subfolders.
var appDir = System.IO.Path.Combine(baseDir, "bin");
var pluginDir = System.IO.Path.Combine(baseDir, "plugins");
appDomainInfo.PrivateBinPath = pluginDir + ";" + appDir;
AppDomain ad = AppDomain.CreateDomain(friendlyName, null, appDomainInfo);
AppDomainManager appDomainManager = ad.DomainManager;
try
{
int exitCode = ad.ExecuteAssembly(assemblyFilename);
System.Diagnostics.Trace.WriteLine(string.Format("ExitCode={0}", exitCode));
System.Diagnostics.Trace.WriteLine("Executed Run");
}
catch (System.Exception)
{
string message = string.Format("Unhandled Exception in {0}",
System.IO.Path.GetFileNameWithoutExtension(assemblyFilename));
System.Diagnostics.Trace.WriteLine(message);
}
finally
{
AppDomain.Unload(ad);
}
}
}
运行示例
在这个特定的实现中,我们告诉应用程序使用子文件夹 bin 和 plugins,并完全忽略根文件夹。我们使用 SampleApp5.exe 进行演示,它只是打印 "--- abc ---"。我在 bin 文件夹中制作了一个修改版的 TestLib.dll,它打印 "*** abc ***" 以获得输出的视觉差异。
AppSupervisor.exe
对于测试、日志记录和诊断,记录运行时数据可能会很有用。我见过太多 try/catch
的误用。有时异常会被默默忽略,导致应用程序处于损坏状态,然后在其他代码运行时应用程序稍后崩溃。我们将创建一个小型异常记录器,它将记录发生的所有托管异常,无论它们是否被处理。这样我们就可以回顾并查看这些异常是否确实是出于正确的原因发生的。我们将通过在所有 AppDomains 中注册 FirstChanceException
的处理程序来实现这一点。
实现
public sealed class AppDomainManagerSupervisor : AppDomainManager, IAppDomainManager
{
public override AppDomain CreateDomain(string friendlyName,
Evidence securityInfo, AppDomainSetup appDomainInfo)
{
Trace("CreateDomain");
System.Diagnostics.Trace.WriteLine(string.Format("AppDomain::CreateDomain({0})", friendlyName));
var appDomain = base.CreateDomain(friendlyName, securityInfo, appDomainInfo);
appDomain.FirstChanceException += AppDomainFirstChanceException;
return appDomain;
}
public static void AppDomainFirstChanceException(object sender,
System.Runtime.ExceptionServices.FirstChanceExceptionEventArgs e)
{
System.Console.Error.WriteLine("AppDomainFirstChanceException - First Chance Exception");
var sb = new StringBuilder();
sb.AppendLine("AppDomainFirstChanceException - First Chance Exception");
sb.AppendLine(e.Exception.StackTrace);
System.Diagnostics.Trace.WriteLine(sb.ToString());
}
}
运行示例
SampleApp1.exe 抛出一个 NotImplementedException
,但该异常在程序内部被捕获,所以我们永远不会知道。通过 AppSupervisor.exe 运行它,它将被检测为第一次机会异常并记录到 DebugOutput
。
AppExperimental.exe
为 Supervisor 自然的附加功能是添加一个 UnhandledExceptionHandler
。通常,如果一个异常没有被捕获,它最终会导致应用程序崩溃。它会先展开堆栈并继续查找异常处理程序,直到没有堆栈可展开。可以做的是,在应用程序级别或 AppDomain 级别添加一个全局异常处理程序。
实现
public sealed class AppDomainManagerExperimental : AppDomainManager, IAppDomainManager
{
public void Run(string assemblyFilename, string friendlyName)
{
// Add Exception Handler on Appliction level
Application.ThreadException += ApplicationThreadException;
// Add Exception Handler on Current Domain
AppDomain.CurrentDomain.UnhandledException += AppDomainUnhandledException;
AppDomain ad = AppDomain.CreateDomain(friendlyName);
// Add Exception Handler on newly created domain
ad.UnhandledException += AppDomainUnhandledException;
AppDomainManager appDomainManager = ad.DomainManager;
try
{
int exitCode = ad.ExecuteAssembly(assemblyFilename);
System.Diagnostics.Trace.WriteLine(string.Format("ExitCode={0}", exitCode));
}
catch (System.Exception)
{
string message = string.Format("Unhandled Exception in {0}",
System.IO.Path.GetFileNameWithoutExtension(assemblyFilename));
Trace(message);
System.Console.Error.WriteLine(message);
}
finally
{
AppDomain.Unload(ad);
}
}
}
运行示例
为了演示,我将使用 SampleApp2.exe。它抛出一个 NotImplementedException
,而该异常未被任何处理程序捕获。
有趣的是……它没有进入我们的任何异常处理程序。
SampleApp3.exe 的行为与 SampleApp2.exe 完全相同,但它实际上自己安装了未处理的异常处理程序。
仔细看。处理程序停止工作。
由 SampleApp3.exe 安装的未处理异常处理程序停止工作。这是 CLR 中的一个已知错误。它是在 2.0 运行时报告的。您可以在此处阅读更多信息:CLR 宿主非 CLR 创建线程中的异常处理。解释是:“该行为确实是 CLR 执行引擎和 CRT 竞争 UnhandledExceptionFilter
造成的错误”。我们通过 COM 执行,它会自动插入一个 try/catch
。未处理异常过滤器只能设置一次。COM 首先设置它。然后 CLR 尝试这样做。根据此 页面,它应该在 v4.0 版本中修复。但我没有成功。如果有人成功了,请告知。
替换内存管理器
IHostControl::GetHostManager
是一个接口,您可以在其中插入自己的内存处理程序实现。
我们将看看如何做到这一点。
实现
首先,我们需要一个修改过的 IHostControl
实现,并提供一个名为 MyHostMemoryManager
的自定义内存管理器。
HRESULT STDMETHODCALLTYPE MyHostControlMemoryManager::GetHostManager(REFIID riid,void **ppv)
{
if (riid == IID_IHostMemoryManager)
{
IHostMemoryManager *pMemoryManager = new MyHostMemoryManager();
*ppv = pMemoryManager;
return S_OK;
}
// IID_IHostTaskManager
// IID_IHostThreadpoolManager
// IID_IHostSyncManager
// IID_IHostAssemblyManager
// IID_IHostGCManager
// IID_IHostPolicyManager
// IHostSecurityManager
else
{
*ppv = NULL;
return E_NOINTERFACE;
}
}
现在我们完成了 IHostControl
实现。下一步是实现 IHostMemoryManager
。
class MyHostMemoryManager : public IHostMemoryManager
{
public:
virtual HRESULT STDMETHODCALLTYPE CreateMalloc(
/* [in] */ DWORD dwMallocType,
/* [out] */ IHostMalloc **ppMalloc)
{
*ppMalloc = new MyHostMalloc();
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE VirtualAlloc(
/* [in] */ void *pAddress,
/* [in] */ SIZE_T dwSize,
/* [in] */ DWORD flAllocationType,
/* [in] */ DWORD flProtect,
/* [in] */ EMemoryCriticalLevel eCriticalLevel,
/* [out] */ void **ppMem)
{
*ppMem = ::VirtualAlloc(pAddress, dwSize, flAllocationType, flProtect);
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE VirtualFree(
/* [in] */ LPVOID lpAddress,
/* [in] */ SIZE_T dwSize,
/* [in] */ DWORD dwFreeType)
{
::VirtualFree(lpAddress, dwSize, dwFreeType);
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE VirtualQuery(
/* [in] */ void *lpAddress,
/* [out] */ void *lpBuffer,
/* [in] */ SIZE_T dwLength,
/* [out] */ SIZE_T *pResult)
{
*pResult = ::VirtualQuery(lpAddress, (PMEMORY_BASIC_INFORMATION) lpBuffer, dwLength);
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE VirtualProtect(
/* [in] */ void *lpAddress,
/* [in] */ SIZE_T dwSize,
/* [in] */ DWORD flNewProtect,
/* [out] */ DWORD *pflOldProtect)
{
::VirtualProtect(lpAddress, dwSize, flNewProtect, pflOldProtect);
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE GetMemoryLoad(
/* [out] */ DWORD *pMemoryLoad,
/* [out] */ SIZE_T *pAvailableBytes)
{
*pMemoryLoad = 30; // percent
*pAvailableBytes = 100 * 1024*1024;
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE RegisterMemoryNotificationCallback(
/* [in] */ ICLRMemoryNotificationCallback *pCallback)
{
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE NeedsVirtualAddressSpace(
/* [in] */ LPVOID startAddress,
/* [in] */ SIZE_T size)
{
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE AcquiredVirtualAddressSpace(
/* [in] */ LPVOID startAddress,
/* [in] */ SIZE_T size)
{
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE ReleasedVirtualAddressSpace(
/* [in] */ LPVOID startAddress)
{
return S_OK;
}
};
我的天!这是一个糟糕的实现。没有错误处理。甚至跳过了某些方法的实现。永远不要在实际项目中使用此代码。这里重要的方法实际上是 MyHostMemoryManager::CreateMalloc
,我们在其中实现 IHostMalloc
接口。
class MyHostMalloc : public IHostMalloc
{
public:
HRESULT STDMETHODCALLTYPE Alloc(SIZE_T cbSize,
EMemoryCriticalLevel eCriticalLevel,
void** ppMem)
{
void* memory = new char[cbSize];
*ppMem = memory;
g_noAllocs++;
return S_OK;
}
HRESULT STDMETHODCALLTYPE DebugAlloc(SIZE_T cbSize,
EMemoryCriticalLevel eCriticalLevel,
char* pszFileName,
int iLineNo,
void** ppMem)
{
void* memory = new char[cbSize];
ZeroMemory(memory, cbSize);
*ppMem = memory;
return S_OK;
}
HRESULT STDMETHODCALLTYPE Free(void* pMem)
{
g_noFrees++;
delete [] pMem;
return S_OK;
}
};
IHostMalloc
的实现很简单。它只是一个虚拟实现,将调用转发给 new
和 delete
。可以做的是在内存区域之前和之后添加魔术数字,或添加您自己的计数器。我添加了两个计数器,一个用于分配 (Alloc) 的数量,一个用于释放 (Free) 的数量。
仅供记录,new
和 delete
非常慢。添加更多 CPU 没有帮助,对 new
和 delete
的调用永远不会并行运行。因此,硬件必须保证内存区域只分配一次。一个好的内存管理器应该一次分配一大块内存并分配其部分,从而避免停顿。嘿,就像 CLR 运行时中的默认内存管理器一样。 " />
运行示例
内存管理器存在于所有 AppLaunchers 中,只需将 "-mem" 作为第二个参数传递。
最后两行包含由我们的自定义实现实现的计数器。
故障排除
.net 应用程序可能由于多种原因无法启动。其中一个原因是找不到程序集,或者找不到 .config 文件。 FUSLOGVW.exe 是一个用于排查程序集加载错误的出色工具。
WPF 应用程序可能无法启动。我必须将 AppLauncher 的主线程标记为 STA(单线程单元)。在 WPF 中,只有 STA 线程才能分配对象。
.net 应用程序将在 AppLauncher 的进程空间中运行。它不会读取正确的配置文件。请将 MyNetApp.exe.config 复制到 AppLauncher.exe.config。
DLL 的探测路径和搜索路径可能不正确。程序集仅从当前目录或子文件夹加载。从一个位置启动 AppLauncher 并从另一个位置加载 DLL 似乎不起作用。存在权限错误。
所有这些错误都可以通过 FUSLOGVW.exe 发现。
结论
不幸的是,UnhandledException
处理程序不起作用。就个人而言,能够获得额外的日志记录来测试或诊断应用程序中的错误将很有用。我认为使用 CLR 宿主接口具有一定的潜力。如果您使用这些接口编写了很酷且有用的东西,请告知。
关注点
本文是 CLR 宿主 - 自定义 CLR 的后续文章,必读。当然,主要文档来源是 MSDN 本身,.NET Framework 2.0 宿主接口。
关于 CLR 宿主 API 的一本很棒的书是 Customizing the Microsoft® .NET Framework Common Language Runtime。它有点旧,但它是最好的(也许也是唯一一本存在的)。
历史
- 2012 年 7 月 11 日,初始帖子
- 2019 年 5 月 24 日,更新源代码以支持 WPF 应用程序,并添加了故障排除部分。