原文:http://www.blogcn.com/User8/flier_lu/index.html?id=3692778
在传统的操作系统级安全模型中,安全管理的粒度都是 Principal-based 层面的。用户从认证登陆成功开始,就获得此帐号的所有权限,而其运行的程序,也自动被授予帐号及其所在组的所有权限。例如我在《DACL, NULL or not NULL》一文中介绍的,NT 用户从登陆到系统建立 Session 开始,就缺省使用相同权限,新建进程自动获得父进程的权限,除非程序本身手动进行限制。而 *nix 系统下面的思路也是类似,只不过从 Token 编程了各种 uid/gid 等等。
这种 Principal-based 的安全模型,在以主机为中心的孤立环境中是非常合适的,而且足够简单和高效。但随着网络的普遍使用,这种安全模型开始受到挑战,最直接的就是如何处理从网络上运行程序的策略问题。按照现有模型,所有程序都会自动获得最大权限集,但这显然是不现实的,安全管理粒度过于粗放。
因此在 Java 和 IE 等涉及网络的应用程序中,提出了新的基于位置的安全模型。一个程序运行时获得的权限,并不由其父进程或者说宿主来决定(这些程序往往用于较高权限),而是由其程序所在位置觉得具有多少权限。Java 中将之简化为对代码源的安全策略限定,如在策略文件中指定所有来自 www.nsfocus.com 的程序都有写 c:\temp 目录的权限,而来自其他网址的程序只能读取 c:\temp 目录内容。而 IE 中则更进一步,将这些来源分类为本机(my computer)、内网(intranet)、外网(internet)、可信站点(trusted)和不可信站点(untrusted)。
如果说 Principal-based 的安全模型中,关键因素是:我是谁(当前帐号)、我要访问什么(目标资源)、我要怎么访问(操作类型);则在 基于位置的安全模型中变成了:我来自哪里(代码来源)、我要访问什么(目标资源)、我要怎么访问(操作类型)。关键因素虽然只有一个我是谁到我来自哪里的转变,但其控制粒度能够大大提升。
但是这样的基于位置的安全模型还是存在其问题,因为组件之间的可信程度是不同的。例如一个本机控件因为来源于本机,受到系统的信任,被赋予很高的权限。同时一个恶意代码来源于不受信任的位置,无法执行某项操作。如果权限限定完整的话,本来不会出现问题,但因为某些权限依赖关系的管理混乱,造成恶意代码可以通过受信任代码执行本不应允许他执行的功能。这也是众多 IE 相关漏洞的问题根本所在,其受到 IE 现有安全模型的单级信任机制的限制,注定无法彻底解决。
要彻底解决此类问题,归根结底必须建立一个可信链验证机制,也就是说执行某个操作的时候,必须检查此操作所有的上级操作组件是否拥有安全权限,而不仅仅只检测最后一级的组件。这一思路正是 CLR 中代码访问安全检测的设计思路,其关键因素增加了一个:调用我的人都有哪些,他们是否有相应权限的检测。
例如我在建立一个文件的时候,可以强制性检测调用链上的所有组件是否都拥有操作此文件的权限:
FileIOPermission 类描述了我需要检测的权限,对 C:\SomeFile.txt 文件可写;FileIOPermission.Demand() 则执行这一权限的检测工作,遍历此方法调用链上的所有组件,检测他们是否有次权限。这样一来就可以从理论上避免恶意代码通过调用可信组件执行越权操作的问题。具体的权限定义和使用方法,这里就不详细介绍了。下面就这种检测如何实现做一个结构上的简要分析。
为进行代码访问权限检测,CLR 缺省定义了以上这些权限类型。首先他们都是从 CodeAccessPermission 类型继承出来,其次就其意义可进一步分为代码访问权限和代码身份权限。代码访问权限定义代码将如何去访问资源,如读写文件、弹出对话框等等;代码身份权限则定义访问此资源的组件必须符合什么样的身份,如只能是本地文件、或者只能是 nsfocus.com 发布的组件等等。
虽然权限种类众多,但各种子类只负责定义自身权限的特性以及如何对自身权限验证,而所有的调用链遍历和验证工作,都是由 CodeAccessPermission.Demand() 方法完成的:
可以看到 CodeAccessPermission.Demand 方法,实际上是将验证操作转发给安全管理器 SecurityManager 的代码访问安全引擎 CodeAccessSecurityEngine 类型的 Check 方法完成的。
CodeAccessSecurityEngine 内部类的 Check 方法,将最终调用通过 Unmanaged 代码实现的内部方法进行安全检测。rotor 中的 COMCodeAccessSecurityEngine 类型 (ComCodeAccessSecurityEngine.cpp) 实现了这个检测逻辑。
COMCodeAccessSecurityEngine::Check 函数 (ComCodeAccessSecurityEngine.cpp:683) 通过调用 COMCodeAccessSecurityEngine::CheckInternal 函数 (ComCodeAccessSecurityEngine.cpp:697) 填充一个堆栈遍历请求结构 CasCheckWalkData 的内容,最终将请求转发给 StandardCodeAccessCheck 函数 (ComCodeAccessSecurityEngine.cpp:563) 完成检测。此结构的指针将作为堆栈遍历回调函数的参数传递给回调函数进行实际权限验证,而 StandardCodeAccessCheck 只是负责调用全局堆栈遍历支持 StackWalkFunctions 函数(StackWalk.cpp:512),以 CodeAccessCheckStackWalkCB 函数 (ComCodeAccessSecurityEngine.cpp:449) 为回调函数,以 CheckInternal 函数填充的 CasCheckWalkData 结构为参数,通过现成的堆栈遍历支持 Thread::StackWalkFrames 完成堆栈遍历。
通过堆栈遍历实现代码访问安全检测调用流程如下:
因此现在 CAS 检测的问题被分为两个部分:如何遍历调用堆栈;如何检测某个组件是否拥有权限。
过于如何遍历调用对象,因为涉及到比较复杂的堆栈帧类型处理,这里就不详细解释,等有空专门写篇文章介绍。目前需要了解的是 CLR 将堆栈切分成各种不同类型的帧,每个帧代表一种状态的迁移。例如调用一个函数、从一个 Assembly 调用另一个 Assembly、从一个 AppDomain 调用另一个 AppDomain、乃至从 Managed 代码调用 Unmanaged 代码等等。而这些帧又通过链表被串到一起。堆栈遍历的工作实际上就是从当前调用帧开始,反向遍历所有的堆栈帧,处理能够处理的,跳过不能处理的,最终到栈顶结束遍历。如果遍历过程中,堆栈帧的回调处理函数发现问题,则可以通过设置标志中断堆栈遍历操作,返回异常给上级程序进行处理。如 CodeAccessCheckStackWalkCB 发现某个调用链上的方法所在 Assembly 权限不够,则中断遍历操作抛出异常。
对检测某个组件是否拥有权限的 CodeAccessCheckStackWalkCB 函数 (ComCodeAccessSecurityEngine.cpp:449) 来说,其主要工作如下:
从当前帧中可以获取各种需要检测的信息,如方法、Assembly和AppDomain。只有当 Assembly 或 AppDomain 发生变化时,对新的对象进行 CAS 检测,同时如果帧具有显式的安全对象,也要进行 CAS 检测。不过对于符合 Security::SecWalkCommonProlog 函数 (Security.cpp:406) 定义的特殊帧,将完全跳过 CAS 检测。
首先来看看 Security::SecWalkCommonProlog 函数 (Security.cpp:406) 定义的特殊帧。首先会跳过遍历操作的调用者自己的帧;然后当进行堆栈遍历指定最大帧数为1时,跳过所有 Reflection/Remoting 调用的内部帧;最后针对 LookForMyCallersCaller 这种特殊调用,以及指定最大帧数进行处理。
在跳过了这些无需处理的堆栈帧后,CodeAccessCheckStackWalkCB 函数将对 Assembly/AppDomain 变化,和显式指定安全对象的情况,进行 CAS 检测。
前面介绍堆栈帧时曾经提到过,对所有跨越 Assembly/AppDomain 的调用,都会有特殊的帧被放入堆栈中做标记。因此在调用链跨越两个 Assembly/AppDomain 时,堆栈遍历回调函数将有一个合适的时机,检测新的 Assembly/AppDomain 是否拥有足够权限。这两类检测思路类似,都是先通过 Assembly::GetSecurityDescriptor 函数 (Assembly.cpp:747) 或 AppDomain::GetSecurityDescriptor 函数 (AppDomain.cpp:1146) 获取一个安全描述符 SecurityDescriptor;然后对需要进行 CAS 检测的情况,调用 SecurityDescriptor::GetGrantedPermissionSet 函数 (Security.cpp:2218) 获得相关权限集;最后调用回调函数传入参数中的 pfnCheckGrants 函数指针,进行权限验证。
值得注意的是,当安全描述符被标记为完全可信任 (SecurityDescriptor::IsFullyTrusted())、堆栈遍历参数指定非受限模式 (IUnrestrictedPermission)、以及 Assembly 是系统 BCL 类库(mscorlib.dll) 或 AppDomain 是缺省 AppDomain (用于加载 BCL 类库,具体说明参见《用WinDbg探索CLR世界 [6] AppDomain 的创建过程 》一文),则忽略 CAS 检测。
对完全可信任概念的含义,可以参考《可怕的 Fully Trusted Code》一文。
如果需要进行 CAS 检测,则 CheckGrants 函数 (ComCodeAccessSecurityEngine.cpp:128) 将完成权限的验证工作。而其实际工作,则将通过 Managed 方法 CodeAccessSecurityEngine::CheckHelper 方法 (CodeAccessSecurityEngine.cs:230) 完成。而 CheckHelper 方法将通过权限类型本身的 IsSubsetOf/Intersect 等方法的实现,来判断 Assembly/AppDomain 现有权限集,是否包括请求的权限。而 Assembly/AppDomain 现有权限集,则是在 Assembly 被载入以及 AppDomain 被创建时,由 CLR Loader 创建的。以后有机会再专门写篇文章分析这个权限集的构建逻辑。
最后 CodeAccessCheckStackWalkCB 还需要处理显式指定了安全对象帧的情况。对帧安全对象进行检测的 CheckFrameData 方法 (ComCodeAccessSecurityEngine.cpp:231) 与 CheckGrants 类似,也是最终通过 CheckHelper 方法实现的,这里就不罗嗦了。堆栈帧的安全对象,等到介绍堆栈结构的时候再详细解释。
至此,CLR 中代码访问安全检测的大致实现思路以及比较清晰了,等把堆栈帧结构和 CLR Loader 安全权限集构建的文章弄完,再整理篇完整的,呵呵。
在传统的操作系统级安全模型中,安全管理的粒度都是 Principal-based 层面的。用户从认证登陆成功开始,就获得此帐号的所有权限,而其运行的程序,也自动被授予帐号及其所在组的所有权限。例如我在《DACL, NULL or not NULL》一文中介绍的,NT 用户从登陆到系统建立 Session 开始,就缺省使用相同权限,新建进程自动获得父进程的权限,除非程序本身手动进行限制。而 *nix 系统下面的思路也是类似,只不过从 Token 编程了各种 uid/gid 等等。
这种 Principal-based 的安全模型,在以主机为中心的孤立环境中是非常合适的,而且足够简单和高效。但随着网络的普遍使用,这种安全模型开始受到挑战,最直接的就是如何处理从网络上运行程序的策略问题。按照现有模型,所有程序都会自动获得最大权限集,但这显然是不现实的,安全管理粒度过于粗放。
因此在 Java 和 IE 等涉及网络的应用程序中,提出了新的基于位置的安全模型。一个程序运行时获得的权限,并不由其父进程或者说宿主来决定(这些程序往往用于较高权限),而是由其程序所在位置觉得具有多少权限。Java 中将之简化为对代码源的安全策略限定,如在策略文件中指定所有来自 www.nsfocus.com 的程序都有写 c:\temp 目录的权限,而来自其他网址的程序只能读取 c:\temp 目录内容。而 IE 中则更进一步,将这些来源分类为本机(my computer)、内网(intranet)、外网(internet)、可信站点(trusted)和不可信站点(untrusted)。
如果说 Principal-based 的安全模型中,关键因素是:我是谁(当前帐号)、我要访问什么(目标资源)、我要怎么访问(操作类型);则在 基于位置的安全模型中变成了:我来自哪里(代码来源)、我要访问什么(目标资源)、我要怎么访问(操作类型)。关键因素虽然只有一个我是谁到我来自哪里的转变,但其控制粒度能够大大提升。
但是这样的基于位置的安全模型还是存在其问题,因为组件之间的可信程度是不同的。例如一个本机控件因为来源于本机,受到系统的信任,被赋予很高的权限。同时一个恶意代码来源于不受信任的位置,无法执行某项操作。如果权限限定完整的话,本来不会出现问题,但因为某些权限依赖关系的管理混乱,造成恶意代码可以通过受信任代码执行本不应允许他执行的功能。这也是众多 IE 相关漏洞的问题根本所在,其受到 IE 现有安全模型的单级信任机制的限制,注定无法彻底解决。
要彻底解决此类问题,归根结底必须建立一个可信链验证机制,也就是说执行某个操作的时候,必须检查此操作所有的上级操作组件是否拥有安全权限,而不仅仅只检测最后一级的组件。这一思路正是 CLR 中代码访问安全检测的设计思路,其关键因素增加了一个:调用我的人都有哪些,他们是否有相应权限的检测。
例如我在建立一个文件的时候,可以强制性检测调用链上的所有组件是否都拥有操作此文件的权限:
|
FileIOPermission 类描述了我需要检测的权限,对 C:\SomeFile.txt 文件可写;FileIOPermission.Demand() 则执行这一权限的检测工作,遍历此方法调用链上的所有组件,检测他们是否有次权限。这样一来就可以从理论上避免恶意代码通过调用可信组件执行越权操作的问题。具体的权限定义和使用方法,这里就不详细介绍了。下面就这种检测如何实现做一个结构上的简要分析。
以下为引用:
System.Security.CodeAccessPermission
System.Security.Permissions.EnvironmentPermission
System.Security.Permissions.FileDialogPermission
System.Security.Permissions.FileIOPermission
System.Security.Permissions.ReflectionPermission
System.Security.Permissions.RegistryPermission
System.Security.Permissions.SecurityPermission
System.Security.Permissions.UIPermission
System.Security.Permissions.IsolatedStoragePermission
System.Security.Permissions.IsolatedStorageFilePermission
System.Security.Permissions.StrongNameIdentityPermission
System.Security.Permissions.PublisherIdentityPermission
System.Security.Permissions.SiteIdentityPermission
System.Security.Permissions.UrlIdentityPermission
System.Security.Permissions.ZoneIdentityPermission
为进行代码访问权限检测,CLR 缺省定义了以上这些权限类型。首先他们都是从 CodeAccessPermission 类型继承出来,其次就其意义可进一步分为代码访问权限和代码身份权限。代码访问权限定义代码将如何去访问资源,如读写文件、弹出对话框等等;代码身份权限则定义访问此资源的组件必须符合什么样的身份,如只能是本地文件、或者只能是 nsfocus.com 发布的组件等等。
虽然权限种类众多,但各种子类只负责定义自身权限的特性以及如何对自身权限验证,而所有的调用链遍历和验证工作,都是由 CodeAccessPermission.Demand() 方法完成的:
|
可以看到 CodeAccessPermission.Demand 方法,实际上是将验证操作转发给安全管理器 SecurityManager 的代码访问安全引擎 CodeAccessSecurityEngine 类型的 Check 方法完成的。
|
CodeAccessSecurityEngine 内部类的 Check 方法,将最终调用通过 Unmanaged 代码实现的内部方法进行安全检测。rotor 中的 COMCodeAccessSecurityEngine 类型 (ComCodeAccessSecurityEngine.cpp) 实现了这个检测逻辑。
COMCodeAccessSecurityEngine::Check 函数 (ComCodeAccessSecurityEngine.cpp:683) 通过调用 COMCodeAccessSecurityEngine::CheckInternal 函数 (ComCodeAccessSecurityEngine.cpp:697) 填充一个堆栈遍历请求结构 CasCheckWalkData 的内容,最终将请求转发给 StandardCodeAccessCheck 函数 (ComCodeAccessSecurityEngine.cpp:563) 完成检测。此结构的指针将作为堆栈遍历回调函数的参数传递给回调函数进行实际权限验证,而 StandardCodeAccessCheck 只是负责调用全局堆栈遍历支持 StackWalkFunctions 函数(StackWalk.cpp:512),以 CodeAccessCheckStackWalkCB 函数 (ComCodeAccessSecurityEngine.cpp:449) 为回调函数,以 CheckInternal 函数填充的 CasCheckWalkData 结构为参数,通过现成的堆栈遍历支持 Thread::StackWalkFrames 完成堆栈遍历。
通过堆栈遍历实现代码访问安全检测调用流程如下:
以下为引用:
CodeAccessSecurityEngine::Check 内部调用定义,由下面的函数实现
COMCodeAccessSecurityEngine::Check 转发检测请求 (ComCodeAccessSecurityEngine.cpp:683)
COMCodeAccessSecurityEngine::CheckInternal 填充 CasCheckWalkData 结构 (ComCodeAccessSecurityEngine.cpp:697)
StandardCodeAccessCheck 执行堆栈遍历
Thread::StackWalkFrames 遍历当前线程堆栈
CodeAccessCheckStackWalkCB 检测当前组件权限 (ComCodeAccessSecurityEngine.cpp:449)
因此现在 CAS 检测的问题被分为两个部分:如何遍历调用堆栈;如何检测某个组件是否拥有权限。
过于如何遍历调用对象,因为涉及到比较复杂的堆栈帧类型处理,这里就不详细解释,等有空专门写篇文章介绍。目前需要了解的是 CLR 将堆栈切分成各种不同类型的帧,每个帧代表一种状态的迁移。例如调用一个函数、从一个 Assembly 调用另一个 Assembly、从一个 AppDomain 调用另一个 AppDomain、乃至从 Managed 代码调用 Unmanaged 代码等等。而这些帧又通过链表被串到一起。堆栈遍历的工作实际上就是从当前调用帧开始,反向遍历所有的堆栈帧,处理能够处理的,跳过不能处理的,最终到栈顶结束遍历。如果遍历过程中,堆栈帧的回调处理函数发现问题,则可以通过设置标志中断堆栈遍历操作,返回异常给上级程序进行处理。如 CodeAccessCheckStackWalkCB 发现某个调用链上的方法所在 Assembly 权限不够,则中断遍历操作抛出异常。
对检测某个组件是否拥有权限的 CodeAccessCheckStackWalkCB 函数 (ComCodeAccessSecurityEngine.cpp:449) 来说,其主要工作如下:
|
从当前帧中可以获取各种需要检测的信息,如方法、Assembly和AppDomain。只有当 Assembly 或 AppDomain 发生变化时,对新的对象进行 CAS 检测,同时如果帧具有显式的安全对象,也要进行 CAS 检测。不过对于符合 Security::SecWalkCommonProlog 函数 (Security.cpp:406) 定义的特殊帧,将完全跳过 CAS 检测。
首先来看看 Security::SecWalkCommonProlog 函数 (Security.cpp:406) 定义的特殊帧。首先会跳过遍历操作的调用者自己的帧;然后当进行堆栈遍历指定最大帧数为1时,跳过所有 Reflection/Remoting 调用的内部帧;最后针对 LookForMyCallersCaller 这种特殊调用,以及指定最大帧数进行处理。
|
在跳过了这些无需处理的堆栈帧后,CodeAccessCheckStackWalkCB 函数将对 Assembly/AppDomain 变化,和显式指定安全对象的情况,进行 CAS 检测。
前面介绍堆栈帧时曾经提到过,对所有跨越 Assembly/AppDomain 的调用,都会有特殊的帧被放入堆栈中做标记。因此在调用链跨越两个 Assembly/AppDomain 时,堆栈遍历回调函数将有一个合适的时机,检测新的 Assembly/AppDomain 是否拥有足够权限。这两类检测思路类似,都是先通过 Assembly::GetSecurityDescriptor 函数 (Assembly.cpp:747) 或 AppDomain::GetSecurityDescriptor 函数 (AppDomain.cpp:1146) 获取一个安全描述符 SecurityDescriptor;然后对需要进行 CAS 检测的情况,调用 SecurityDescriptor::GetGrantedPermissionSet 函数 (Security.cpp:2218) 获得相关权限集;最后调用回调函数传入参数中的 pfnCheckGrants 函数指针,进行权限验证。
值得注意的是,当安全描述符被标记为完全可信任 (SecurityDescriptor::IsFullyTrusted())、堆栈遍历参数指定非受限模式 (IUnrestrictedPermission)、以及 Assembly 是系统 BCL 类库(mscorlib.dll) 或 AppDomain 是缺省 AppDomain (用于加载 BCL 类库,具体说明参见《用WinDbg探索CLR世界 [6] AppDomain 的创建过程 》一文),则忽略 CAS 检测。
对完全可信任概念的含义,可以参考《可怕的 Fully Trusted Code》一文。
如果需要进行 CAS 检测,则 CheckGrants 函数 (ComCodeAccessSecurityEngine.cpp:128) 将完成权限的验证工作。而其实际工作,则将通过 Managed 方法 CodeAccessSecurityEngine::CheckHelper 方法 (CodeAccessSecurityEngine.cs:230) 完成。而 CheckHelper 方法将通过权限类型本身的 IsSubsetOf/Intersect 等方法的实现,来判断 Assembly/AppDomain 现有权限集,是否包括请求的权限。而 Assembly/AppDomain 现有权限集,则是在 Assembly 被载入以及 AppDomain 被创建时,由 CLR Loader 创建的。以后有机会再专门写篇文章分析这个权限集的构建逻辑。
|
最后 CodeAccessCheckStackWalkCB 还需要处理显式指定了安全对象帧的情况。对帧安全对象进行检测的 CheckFrameData 方法 (ComCodeAccessSecurityEngine.cpp:231) 与 CheckGrants 类似,也是最终通过 CheckHelper 方法实现的,这里就不罗嗦了。堆栈帧的安全对象,等到介绍堆栈结构的时候再详细解释。
至此,CLR 中代码访问安全检测的大致实现思路以及比较清晰了,等把堆栈帧结构和 CLR Loader 安全权限集构建的文章弄完,再整理篇完整的,呵呵。