原文:http://www.blogcn.com/User8/flier_lu/index.html?id=3783526
我在前一篇文章《CLR 中代码访问安全检测实现原理》(后文中简称为【文1】)中简单介绍了 CLR 中是如何实现 CAS (代码访问安全) 检测,其中提到在对 Assembly/AppDomain 进行权限验证时,是直接从与其绑定的安全描述符中获取其权限集的,然后与需要检查的权限进行集合操作,验证权限是否存在于目标对象的权限集中。
本文中将简要分析 CLR 是如何获取并构造这个与 Assembly/AppDomain 绑定的权限集的,以及如何使用之。
通过【文1】的简要介绍我们可以知道,Java 和 IE 实际上是运行在一个基于位置的安全模型上。
一个组件在 IE 中能够有多大权限,取决于此组件从哪个区域 (Zone) 被加载,使用者可以通过在 IE 安全中为不同 Zone 设置不同的安全权限集,限制不同位置组件的权限。但就如前文所述,IE 这种安全模型有个致命缺陷是缺少一个可信任的调用链,造成恶意代码通过被信任组件执行越权操作的可能性。
Java 的安全模型则相对要完善一些,类型 (Class) 由类加载器 (ClassLoader) 将之与某个代码源 (CodeSource) 绑定,在加载时通过安全策略 (Policy) 获取类型的权限集 (PermissionSet),最终完成类型与权限集的绑定。在需要进行检测时,可以显式通过访问控制器 (AccessControl) 验证当前调用链上类的权限是否符合要求。JVM 安全模型的具体实现细节这里就不详细展开解释了,有兴趣的朋友可以参考《Java 安全》一书。
CLR 的安全模型与 Java 的设计思路非常相似,可以说是在其基础上的一个扩展设计。
首先,CLR 扩展了 Java 和 IE 中位置的概念,不再仅仅将组件所在位置作为组件的身份依据,而是引入了凭据 (Evidence) 这个概念。凭据实际上是一种广义上的代码源的概念,它不仅包括 Java 和 IE 中代码位置和证书,还可以包括组件内容的 Hash 值、发布商签名、来源站点地址等等多种粒度多个角度的证明代码身份的信息类型,同时提供了灵活的扩展机制。
为了灵活性和可扩展性 CLR 又将凭据进一步细分为 Host 凭据和 Assembly 凭据两类:前者包括组件由 CLR 宿主 (host) 载入时显式获得的凭据信息;后者包括用户自定义的嵌入 Assembly 的特殊凭据。
Host Evidence 包括以下类型的信息:
Hash, StrongName, Publisher 是 Assembly 内容的不同层面的凭据;ApplicationDirectory, Zone, Site, Url 则是 Assembly 位置的不同层面的凭据。通过这些不同角度不同粒度的凭据信息,CLR 代码可以很灵活的对代码权限进行限定。
Assembly Evidence 则可以根据用户实际需求自行扩展,如可以增加一个代码作者证书的凭据。
相对来说 Java 的安全模型就较为简单,只有一个固化了的对 URL 和证书提供支持的 CodeSource 类提供代码凭据。而 CLR 中与 CodeSource 类对应的是 Evidence 类,封装了对一组凭据信息的支持。
Assembly 在载入时,可以由载入 Assembly 的宿主显式指定 Evidence,也可以由 fusion 在载入 Assembly 时自动获取载入位置、强签名等信息,生成 Evidence;而 AppDomain 在创建的时候,必须显式指定需要设置的 Evidence,否则会自动重用当前 AppDomain 的 Evidence。载入后的 Assembly 和创建的 AppDomain 可以通过 Evidence 属性访问与之绑定的凭据信息。
下面将对 Assembly.Load 和 AppDomain.CreateDomain 这两组方法进行简单的实现原理分析,了解 Evidence 的创建过程。
CLR 中并没有一个严格意义上与 JVM ClassLoader 对应的类型,而是将其功能分布在 Assembly, AppDomain 等多个类型上。例如虽然可以使用 Assembly.Load 和 AppDomain.Load 等众多方法将指定 Assembly 载入到特定的 AppDomain 中,但实际上最终都是通过调用 Assembly.InternalLoad 方法,使用 Fusion 的底层支持完成载入工作。而 JVM 的 ClassLoader 则给予用户相对更多一些的灵活性,自身的功能内聚性更强。
首先来看看 Assembly 的载入过程:
AppDomain.Load 和 Assembly.Load 方法都是通过指定的 Assembly 名字将之载入,与之对应的还有 xxx.LoadFile, xxx.LoadFrom 等方法,不过因为主要流程区别不大,这儿就不详细分析了。
Assembly.InternalLoad 方法 (bcl\system\reflection\assembly.cs:1022) 主要工作是完成对参数的验证和目标组件访问权限的验证。因为载入 Assembly 是一项需要严格受限的特性,只有受到高度信任的程序才能执行此功能。此外 InternalLoad 方法还会根据目标 Assembly 的所在,检查当前代码调用链是否具有访问目标文件的权限。最后会调用内部方法 Assembly.nLoad 完成实际的 Assembly 的载入。(过于内部方法的相关概念,请参考我另外一篇文章 InternalCall 的使用与实现》)
实现 Assembly.nLoad 的 AssemblyNative::Load 函数 (vm\AssemblyNative.cpp:57),实际上只是为 Fusion 的 AssemblySpec 类做初始化工作。AssemblyNative::Load 函数从参数传入的 AssemblyName 对象等信息中,获取 Assembly 名字、强签名 (StrongName)、代码基 (CodeBase)等信息,初始化 AssemblySpec 类,最终调用 AssemblySpec::LoadAssembly 函数完成 Assembly 的加载。
这里获取的强签名信息 (Public Key, Public Key Token)和 Code Base 是后面生成 Evidence 的重要组成信息,但如果用户在调用 Assembly.Load 时指定了 Evidence 的话 (fIsStringized == true),则部分信息获取工作将被跳过。
AssemblySpec::LoadAssembly 函数 (vm\AssemblySpec.cpp:436) 比较复杂,主要负责从缓存或 Fusion 的搜索路径下,定位并载入 Assembly 的文件,最终调用 BaseDomain::LoadAssembly 函数分析并载入 Assembly 的内容。
AssemblySpec::LoadAssembly 函数伪代码如下:
这里 fusion 定位并载入 Assembly 文件的流程就不详细解释了,有兴趣的朋友可以参看 Fusion 项目组成员 Jufeng Zhang 的 blog。
BaseDomain::LoadAssembly 函数 (vm\AppDomain.cpp:3738) 是实际完成 Assembly 内容分析与载入的所在,伪代码如下:
其中对跨 AppDomain 共享的 Assembly 需要有特殊的处理,但因为与整体流程无关,这里就不展开分析了。
AssemblySecurityDescriptor::Init 函数 (vm\Security.cpp:3126) 从目标 Assembly 对象获取与之绑定的安全描述符,并尝试将自己加入到其共享列表中,如已经有相同的存在则获取相同的那个描述符。而最后调用的 AddDescriptorToDomainList 方法,则进一步将此安全描述符绑定到当前的 AppDomain 的共享安全描述符列表中。
这样一来,Assembly 的安全描述符绑定流程就大概清楚了。
相对于 Assembly 绑定时获取 Evidence 相关信息和绑定安全描述符的工作来说,AppDomain 建立的工作较为简单。
从上面代码片断可以看到,建立 AppDomain 的过程实际上分为创建和设置两个步骤。
创建 AppDomain 的步骤由内部方法 AppDomain.CreateBasicDomain 完成,实现上是通过调用 AppDomainNative::CreateBasicDomain 函数 (vm/AppDomainNative.cpp:48),进一步调用 SystemDomain::NewDomain 函数 (vm/AppDomain.cpp:2480),创建并初始化新的 AppDomain 类型 (vm/AppDomain.hpp:1103),最终获得一个可以跨 AppDomain 操作目标对象的透明代理。
而设置 AppDomain 的步骤由 AppDomain.RemotelySetupRemoteDomain 方法 (bcl/system/appdomain.cs:1288) 完成,负责将创建 AppDomain 时指定的凭证 (providedSecurityInfo) 和创建时的当前 AppDomain 的凭证 (creatorsSecurityInfo) 打包到一起,序列化后通过 AppDomain.InternalRemotelySetupRemoteDomain 方法 (bcl/system/appdomain.cs:1382),将凭证传递给新建 AppDomain。InternalRemotelySetupRemoteDomain 方法则通过建立 AppDomain 时获得的透明代理,将序列化后的凭证集合传递给目标 AppDomain 的 InternalRemotelySetupRemoteDomainHelper 方法 (bcl/system/appdomain.cs:1347),最终调用内部方法 AppDomain.SetupDomainSecurity 完成安全凭证的设置工作。调用路径如下:
AppDomainNative::SetupDomainSecurity 函数 (vm/AppDomainNative.cpp:76) 根据传入 Evidence 情况不同,对指定 AppDomain 的安全描述符进行设置。
在创建并绑定了 Assembly/AppDomain 相关的安全描述符后,我们来看看这些安全描述符是如何被使用的。
Assembly/AppDomain 相关的安全描述符,是进一步获取凭据相关信息的工具。为了效率等原因,Fusion 没有选择在加载 Assembly 或创建 AppDomain 时直接载入所有凭据相关信息,而是通过安全描述符的 GetEvidence 和 Resolve 方法提供延迟载入优化。使用者需要用到凭据相关信息时,访问 Assembly.Evidence 和 AppDomain.Evidence 属性,就会被动引发延迟的凭据相关信息载入操作。
AppDomain.nForceResolve 方法和 Assembly.nGetEvidence 方法分别对应着 AppDomainNative::ForceResolve 函数 (vm\AppDomainNative.cpp:694) 和 Security::GetEvidence 函数 (vm\Security.cpp:4405)。这两个实现函数都是通过 AppDomain/Assembly 绑定的安全上下文完成延迟凭据相关信息载入的。
此处虽然 AppDomain 和 Assembly 的 GetSecurityDescriptor 方法分别会返回 ApplicationSecurityDescriptor 和 AssemblySecurityDescriptor,但他们的 GetEvidence 实现方法并不相同。
AppDomain 安全描述符 ApplicationSecurityDescriptor 在 GetEvidence 方法 (vm/security.cpp:2548) 中,会调用 Managed 代码 AppDomain.CreateSecurityIdentity 方法 (bcl/system/appdomain.cs:1460),将可能存在的根 Assembly 的凭证,与 AppDomain 的凭证合并。伪代码如下:
而 Assembly 安全描述符 AssemblySecurityDescriptor 在 GetEvidence 方法 (vm/security.cpp:2978) 中,则调用Managed 代码 Assembly.CreateSecurityIdentity 方法 (bcl/system/reflection/assembly.cs:880),根据 Assembly 的安全描述符中绑定的安全相关信息,生成新的凭证。
至此,Assembly 载入和 AppDomain 创建时的 Evidence 相关信息获取步骤,以及延迟进行的 Evidence 相关信息解析整理的流程,就大致有了一个轮廓。通过这个分析我们可以大致了解到 CLR 在载入 Assembly 和创建 AppDomain 的过程中,在哪些步骤要搜集哪些信息,以及我们如何将自定义的 Evidence 信息与之配合使用。
我在前一篇文章《CLR 中代码访问安全检测实现原理》(后文中简称为【文1】)中简单介绍了 CLR 中是如何实现 CAS (代码访问安全) 检测,其中提到在对 Assembly/AppDomain 进行权限验证时,是直接从与其绑定的安全描述符中获取其权限集的,然后与需要检查的权限进行集合操作,验证权限是否存在于目标对象的权限集中。
本文中将简要分析 CLR 是如何获取并构造这个与 Assembly/AppDomain 绑定的权限集的,以及如何使用之。
通过【文1】的简要介绍我们可以知道,Java 和 IE 实际上是运行在一个基于位置的安全模型上。
一个组件在 IE 中能够有多大权限,取决于此组件从哪个区域 (Zone) 被加载,使用者可以通过在 IE 安全中为不同 Zone 设置不同的安全权限集,限制不同位置组件的权限。但就如前文所述,IE 这种安全模型有个致命缺陷是缺少一个可信任的调用链,造成恶意代码通过被信任组件执行越权操作的可能性。
Java 的安全模型则相对要完善一些,类型 (Class) 由类加载器 (ClassLoader) 将之与某个代码源 (CodeSource) 绑定,在加载时通过安全策略 (Policy) 获取类型的权限集 (PermissionSet),最终完成类型与权限集的绑定。在需要进行检测时,可以显式通过访问控制器 (AccessControl) 验证当前调用链上类的权限是否符合要求。JVM 安全模型的具体实现细节这里就不详细展开解释了,有兴趣的朋友可以参考《Java 安全》一书。
CLR 的安全模型与 Java 的设计思路非常相似,可以说是在其基础上的一个扩展设计。
首先,CLR 扩展了 Java 和 IE 中位置的概念,不再仅仅将组件所在位置作为组件的身份依据,而是引入了凭据 (Evidence) 这个概念。凭据实际上是一种广义上的代码源的概念,它不仅包括 Java 和 IE 中代码位置和证书,还可以包括组件内容的 Hash 值、发布商签名、来源站点地址等等多种粒度多个角度的证明代码身份的信息类型,同时提供了灵活的扩展机制。
为了灵活性和可扩展性 CLR 又将凭据进一步细分为 Host 凭据和 Assembly 凭据两类:前者包括组件由 CLR 宿主 (host) 载入时显式获得的凭据信息;后者包括用户自定义的嵌入 Assembly 的特殊凭据。
Host Evidence 包括以下类型的信息:
Hash Assembly 内容的 Hash 值
StrongName Assembly 的强签名
Publisher Assembly 发布者的 X509 证书
ApplicationDirectory 应用程序路径
Zone Assembly 来源区域
Site Assembly 来源站点
Url Assembly 来源 URL
Hash, StrongName, Publisher 是 Assembly 内容的不同层面的凭据;ApplicationDirectory, Zone, Site, Url 则是 Assembly 位置的不同层面的凭据。通过这些不同角度不同粒度的凭据信息,CLR 代码可以很灵活的对代码权限进行限定。
Assembly Evidence 则可以根据用户实际需求自行扩展,如可以增加一个代码作者证书的凭据。
相对来说 Java 的安全模型就较为简单,只有一个固化了的对 URL 和证书提供支持的 CodeSource 类提供代码凭据。而 CLR 中与 CodeSource 类对应的是 Evidence 类,封装了对一组凭据信息的支持。
Assembly 在载入时,可以由载入 Assembly 的宿主显式指定 Evidence,也可以由 fusion 在载入 Assembly 时自动获取载入位置、强签名等信息,生成 Evidence;而 AppDomain 在创建的时候,必须显式指定需要设置的 Evidence,否则会自动重用当前 AppDomain 的 Evidence。载入后的 Assembly 和创建的 AppDomain 可以通过 Evidence 属性访问与之绑定的凭据信息。
下面将对 Assembly.Load 和 AppDomain.CreateDomain 这两组方法进行简单的实现原理分析,了解 Evidence 的创建过程。
CLR 中并没有一个严格意义上与 JVM ClassLoader 对应的类型,而是将其功能分布在 Assembly, AppDomain 等多个类型上。例如虽然可以使用 Assembly.Load 和 AppDomain.Load 等众多方法将指定 Assembly 载入到特定的 AppDomain 中,但实际上最终都是通过调用 Assembly.InternalLoad 方法,使用 Fusion 的底层支持完成载入工作。而 JVM 的 ClassLoader 则给予用户相对更多一些的灵活性,自身的功能内聚性更强。
首先来看看 Assembly 的载入过程:
|
AppDomain.Load 和 Assembly.Load 方法都是通过指定的 Assembly 名字将之载入,与之对应的还有 xxx.LoadFile, xxx.LoadFrom 等方法,不过因为主要流程区别不大,这儿就不详细分析了。
Assembly.InternalLoad 方法 (bcl\system\reflection\assembly.cs:1022) 主要工作是完成对参数的验证和目标组件访问权限的验证。因为载入 Assembly 是一项需要严格受限的特性,只有受到高度信任的程序才能执行此功能。此外 InternalLoad 方法还会根据目标 Assembly 的所在,检查当前代码调用链是否具有访问目标文件的权限。最后会调用内部方法 Assembly.nLoad 完成实际的 Assembly 的载入。(过于内部方法的相关概念,请参考我另外一篇文章 InternalCall 的使用与实现》)
|
实现 Assembly.nLoad 的 AssemblyNative::Load 函数 (vm\AssemblyNative.cpp:57),实际上只是为 Fusion 的 AssemblySpec 类做初始化工作。AssemblyNative::Load 函数从参数传入的 AssemblyName 对象等信息中,获取 Assembly 名字、强签名 (StrongName)、代码基 (CodeBase)等信息,初始化 AssemblySpec 类,最终调用 AssemblySpec::LoadAssembly 函数完成 Assembly 的加载。
这里获取的强签名信息 (Public Key, Public Key Token)和 Code Base 是后面生成 Evidence 的重要组成信息,但如果用户在调用 Assembly.Load 时指定了 Evidence 的话 (fIsStringized == true),则部分信息获取工作将被跳过。
|
AssemblySpec::LoadAssembly 函数 (vm\AssemblySpec.cpp:436) 比较复杂,主要负责从缓存或 Fusion 的搜索路径下,定位并载入 Assembly 的文件,最终调用 BaseDomain::LoadAssembly 函数分析并载入 Assembly 的内容。
AssemblySpec::LoadAssembly 函数伪代码如下:
|
这里 fusion 定位并载入 Assembly 文件的流程就不详细解释了,有兴趣的朋友可以参看 Fusion 项目组成员 Jufeng Zhang 的 blog。
BaseDomain::LoadAssembly 函数 (vm\AppDomain.cpp:3738) 是实际完成 Assembly 内容分析与载入的所在,伪代码如下:
|
其中对跨 AppDomain 共享的 Assembly 需要有特殊的处理,但因为与整体流程无关,这里就不展开分析了。
AssemblySecurityDescriptor::Init 函数 (vm\Security.cpp:3126) 从目标 Assembly 对象获取与之绑定的安全描述符,并尝试将自己加入到其共享列表中,如已经有相同的存在则获取相同的那个描述符。而最后调用的 AddDescriptorToDomainList 方法,则进一步将此安全描述符绑定到当前的 AppDomain 的共享安全描述符列表中。
这样一来,Assembly 的安全描述符绑定流程就大概清楚了。
相对于 Assembly 绑定时获取 Evidence 相关信息和绑定安全描述符的工作来说,AppDomain 建立的工作较为简单。
|
从上面代码片断可以看到,建立 AppDomain 的过程实际上分为创建和设置两个步骤。
创建 AppDomain 的步骤由内部方法 AppDomain.CreateBasicDomain 完成,实现上是通过调用 AppDomainNative::CreateBasicDomain 函数 (vm/AppDomainNative.cpp:48),进一步调用 SystemDomain::NewDomain 函数 (vm/AppDomain.cpp:2480),创建并初始化新的 AppDomain 类型 (vm/AppDomain.hpp:1103),最终获得一个可以跨 AppDomain 操作目标对象的透明代理。
|
而设置 AppDomain 的步骤由 AppDomain.RemotelySetupRemoteDomain 方法 (bcl/system/appdomain.cs:1288) 完成,负责将创建 AppDomain 时指定的凭证 (providedSecurityInfo) 和创建时的当前 AppDomain 的凭证 (creatorsSecurityInfo) 打包到一起,序列化后通过 AppDomain.InternalRemotelySetupRemoteDomain 方法 (bcl/system/appdomain.cs:1382),将凭证传递给新建 AppDomain。InternalRemotelySetupRemoteDomain 方法则通过建立 AppDomain 时获得的透明代理,将序列化后的凭证集合传递给目标 AppDomain 的 InternalRemotelySetupRemoteDomainHelper 方法 (bcl/system/appdomain.cs:1347),最终调用内部方法 AppDomain.SetupDomainSecurity 完成安全凭证的设置工作。调用路径如下:
以下为引用:
AppDomain.CreateDomain
AppDomain.CreateBasicDomain - 建立新的 AppDomain 并返回透明代理
AppDomainNative::CreateBasicDomain - 实现 AppDomain.CreateBasicDomain
SystemDomain::NewDomain
AppDomain::GetAppDomainProxy
AppDomain.RemotelySetupRemoteDomain - 设置创建的 AppDomain,序列化凭证集合
AppDomain.InternalRemotelySetupRemoteDomain - 通过透明代理调用目标 AppDomain
AppDomain.InternalRemotelySetupRemoteDomainHelper - 在目标 AppDomain 领域内反序列化凭证集合
AppDomain.SetupDomainSecurity - 设置 AppDomain 的安全凭据
AppDomainNative::SetupDomainSecurity - 实现 AppDomain.SetupDomainSecurity
AppDomainNative::SetupDomainSecurity 函数 (vm/AppDomainNative.cpp:76) 根据传入 Evidence 情况不同,对指定 AppDomain 的安全描述符进行设置。
|
在创建并绑定了 Assembly/AppDomain 相关的安全描述符后,我们来看看这些安全描述符是如何被使用的。
Assembly/AppDomain 相关的安全描述符,是进一步获取凭据相关信息的工具。为了效率等原因,Fusion 没有选择在加载 Assembly 或创建 AppDomain 时直接载入所有凭据相关信息,而是通过安全描述符的 GetEvidence 和 Resolve 方法提供延迟载入优化。使用者需要用到凭据相关信息时,访问 Assembly.Evidence 和 AppDomain.Evidence 属性,就会被动引发延迟的凭据相关信息载入操作。
|
AppDomain.nForceResolve 方法和 Assembly.nGetEvidence 方法分别对应着 AppDomainNative::ForceResolve 函数 (vm\AppDomainNative.cpp:694) 和 Security::GetEvidence 函数 (vm\Security.cpp:4405)。这两个实现函数都是通过 AppDomain/Assembly 绑定的安全上下文完成延迟凭据相关信息载入的。
|
此处虽然 AppDomain 和 Assembly 的 GetSecurityDescriptor 方法分别会返回 ApplicationSecurityDescriptor 和 AssemblySecurityDescriptor,但他们的 GetEvidence 实现方法并不相同。
AppDomain 安全描述符 ApplicationSecurityDescriptor 在 GetEvidence 方法 (vm/security.cpp:2548) 中,会调用 Managed 代码 AppDomain.CreateSecurityIdentity 方法 (bcl/system/appdomain.cs:1460),将可能存在的根 Assembly 的凭证,与 AppDomain 的凭证合并。伪代码如下:
|
而 Assembly 安全描述符 AssemblySecurityDescriptor 在 GetEvidence 方法 (vm/security.cpp:2978) 中,则调用Managed 代码 Assembly.CreateSecurityIdentity 方法 (bcl/system/reflection/assembly.cs:880),根据 Assembly 的安全描述符中绑定的安全相关信息,生成新的凭证。
|
至此,Assembly 载入和 AppDomain 创建时的 Evidence 相关信息获取步骤,以及延迟进行的 Evidence 相关信息解析整理的流程,就大致有了一个轮廓。通过这个分析我们可以大致了解到 CLR 在载入 Assembly 和创建 AppDomain 的过程中,在哪些步骤要搜集哪些信息,以及我们如何将自定义的 Evidence 信息与之配合使用。