编程语言

img Aven

针对 .NET 框架的安全编码指南

发表于2004/10/11 12:41:00  1304人阅读

摘要:公共语言运行库和 Microsoft .NET 框架对所有托管代码应用程序强制实施基于证据的安全性。大多数代码很少需要或完全不需要为安全性进行显式编码。本文简要描述了安全系统,讨论了可能需要在代码中考虑的安全问题,并为分类组件提供了指南,以便您了解为了确保代码的安全可能需要解决什么问题。

前提条件:读者应当熟悉公共语言运行库和 Microsoft(R) .NET 框架,以及基于证据的安全性和代码访问安全性的基本知识。

 

本页内容

 基于证据的安全性和代码访问安全性

 安全编码的目标

 安全编码的方法

 安全编码的最佳做法

 确保状态数据的安全

 确保方法访问的安全

 包装程序代码

 非托管代码

 用户输入

 远程处理注意事项

 受保护的对象

 序列化

 应用程序域跨域问题

 评估权限

 其他安全技术

 

基于证据的安全性和代码访问安全性

结合使用两项单独的技术来保护托管代码:

基于证据的安全性决定将什么权限授予代码。

代码访问安全性负责检查堆栈上的所有代码是否拥有执行某项操作的所需权限。

权限将这两个技术绑定在一起:权限是执行某个特定受保护操作的权利。例如,读取 c:/temp”是一个文件权限;连接到 www.msn.com”是一个网络权限。

基于证据的安全性决定授予代码的权限。证据是有关用作安全策略机制输入的任何程序集(授予权限的单位)的已知信息。假如将证据作为输入,系统将评估由管理员设置的安全策略,以决定可以将什么权限授予代码。代码本身可以使用权限请求来影响被授予的权限。权限请求被表示为使用自定义属性语法的程序集级别的声明性安全性。但是,代码不能以任何方式获取多于或少于策略系统所允许的权限。权限授予只发生一次,指定程序集中所有代码的权利。要查看或编辑安全策略,请使用 .NET 框架配置工具 (Mscorcfg.msc)

下表列出了策略系统用来向代码授予权限的某些常见证据类型。除了这里列出的标准证据类型(它们是由安全系统提供的)以外,还可以使用用户定义的新类型来扩展证据集合。

证据

说明

哈希值

程序集的哈希值

出版商

AuthentiCode(R) 签名者

强名称

公钥+名称+版本

站点

代码来源的 Web 站点

Url

代码来源的 URL

区域

代码来源的 Internet Explorer 区域

代码访问安全性负责处理执行所授予权限的安全检查。这些安全检查的独特方面是,它们不仅会检查试图执行受保护操作的代码,而且会沿堆栈检查它的所有调用方。要让检查获得成功,所有被检查的代码都必须具有所需权限(可以重写)。

安全检查是有好处的,因为它们可以防止引诱攻击。引诱攻击是指未经授权的代码调用您的代码,并引诱您的代码代替未经授权的代码执行某些操作。假设您有一个读取文件的应用程序,并且安全策略将读取文件的权限授予了您的代码。因为您的所有应用程序代码都拥有权限,所以会通过代码访问安全性检查。但是,如果无权访问文件的恶意代码以某种方式调用了您的代码,那么安全检查将失败,这是因为不受信任的代码调用了您的代码,从而在堆栈上可见。

需要注意的是,该安全性的所有方面都是基于允许代码执行什么操作这一机制的。基于登录信息对用户进行授权是基础操作系统完全独立的安全功能。请将这两个安全系统看作多层防御:例如,要访问一个文件,必须要通过代码授权和用户授权。虽然在许多依赖用户登录信息或其他凭据来控制某些用户可以和不可以执行某项操作的应用程序中,用户授权很重要,但这种类型的安全不是本文讨论的重点。

返回页首

 

安全编码的目标

我们假设安全策略是正确的,并且潜在的恶意代码不具有授予信任代码的权限,该权限允许受信任代码安全地执行功能更强大的操作。(如果进行其他假设,将使一种类型的代码无法与其他类型的代码区分开,从而使问题不会发生。)使用强制 .NET 框架的权限和代码中实施的其他措施时,您必须建立障碍来防止恶意代码获得您不希望它得到的信息,或防止它执行恶意的操作。此外,在受信任代码的所有预期情况中,必须在代码的安全性和可用性之间找到一种平衡。

基于证据的安全策略和代码访问安全性为实现安全性提供了非常强大的显式机制。大多数应用程序代码只需要使用 .NET 框架实现的基础结构。在某些情况下,还需要其他特定于应用程序的安全性,该安全性是通过扩展安全系统或使用新的特殊方法生成的。

返回页首

安全编码的方法

这些安全技术的一个优点是,您通常可以忘记它们的存在。如果将代码执行任务所需的权限授予代码,那么一切将正常工作(同时,您将能够抵御潜在的攻击,例如,前面描述的引诱攻击)。但是,在某些特定情况下,您必须显式地处理安全问题。下面的几个部分描述了这些方法。即使这些部分并不直接适用于您,但了解这些安全问题总是有用的。

安全中立代码

安全中立代码不对安全系统执行任何显式操作。它只使用获得的权限来运行。尽管无法捕获受保护操作(例如,使用文件、网络等)的安全异常会导致不良的用户体验(这是指包含许多细节的异常,但对大多数用户来说这些细节是完全模糊的),但这种方式利用了安全技术,因为即使是高度受信任的代码也无法降低安全保护的程度。可能发生的最坏情况是,调用方将需要许多权限,否则会被安全机制禁止运行。

安全中立库具有您应当了解的特殊特征。假设此库提供的 API 元素需要使用文件或调用非托管代码;如果您的代码没有相应的权限,将无法按描述运行。但是,即使该代码拥有权限,调用它的任何应用程序代码也必须拥有相同的权限才能运行。如果呼叫代码没有正确的权限,那么安全异常将作为代码访问安全性堆栈审核的结果出现。如果可以要求调用方对库所执行的所有操作都拥有权限,那么这将是实现安全性的简单且安全的方式,因为它不涉及危险的安全重写。但是,如果想让调用库的应用程序代码不受权限要求的影响,并减少对非常强大的权限的需要,您必须了解与受保护资源配合工作的库模型,这部分内容将在本文的公开受保护资源的库代码部分中进行描述。

不属于可重用组件的应用程序代码

如果您的代码是不会被其他代码调用的应用程序的一部分,那么安全性很简单,并且可能不需要特殊的编码。但请记住,恶意代码可以调用您的代码。虽然代码访问安全性机制可以阻止恶意代码访问资源,但此类恶意代码仍然可以读取可能包含敏感信息的字段或属性的值。

此外,如果您的代码可以从 Internet 或其他不可靠的来源接受用户输入,则必须小心恶意输入。

有关详细信息,请参阅本文的确保状态数据的安全和用户输入。

本机代码实现的托管包装程序

通常在此情况下,某些有用的功能是在本机代码中实现的,并且您想在不改写它的情况下将其用于托管代码。托管包装程序很容易编写为平台调用或使用 COM 互操作。但是,如果您这样做,包装程序的调用方必须拥有非托管代码的权利,调用才能成功。在默认策略下,这意味着从 Intranet Internet 下载的代码将不会与包装程序配合工作。

更好的方法是将这些非托管代码权利只授予包装程序代码,而不要授予使用这些包装程序的所有应用程序。如果基础功能很安全(不公开任何资源)并且实现也很安全,则包装程序只需要断言它的权利,这将使任何代码都能够通过它进行调用。如果涉及到资源,则安全编码应与下一部分中描述的库代码情况相同。因为包装程序向调用方潜在地公开了这些问题,所以需要对本机代码的安全性进行仔细验证,这是包装程序的责任。

有关详细信息,请参阅本文的非托管代码和评估权限部分。

公开受保护资源的库代码

这是功能最强大因此也是潜在最危险(如果方法不正确)的安全编码方式:您的库充当了其他代码用来访问特定资源(这些资源以其他方式是不可用的)的接口,正如 .NET 框架类对它们所使用的资源施加权限一样。无论在哪里公开资源,代码都必须先请求适用于资源的权限(即,执行安全检查),然后通常需要断言它执行实际操作的权利。

有关详细信息,请参阅本文的非托管代码和评估权限部分。

返回页首

安全编码的最佳做法

除非另行指定,代码示例都是用 C# 编写的。

权限请求是使代码获得安全性的好方法。这些请求可让您做两件事:

请求代码运行所必需的最低权限。

确保代码所接收的权限不会超过它实际需要的权限。

例如:

[assembly:FileIOPermissionAttribute
 (SecurityAction.RequestMinimum, Write="C://test.tmp")]
[assembly:PermissionSet
 (SecurityAction.RequestOptional, Unrestricted=false)]
 …SecurityAction.RequestRefused_

该示例告诉系统:除非代码收到写入 C:/test.tmp 的权限,否则不应当运行。如果代码遇到没有授予该权限的安全策略,那么将引发 PolicyException,并且代码不会运行。您可以确保代码将被授予该权限,并且不必担心由于权限太少而导致的错误。

该示例还告诉系统:不需要其他权限。除此之外,代码将被授予策略选择要授予它的任何权限。虽然额外的权限不会导致损害,但如果某处有安全问题,则拥有较少的权限可以很好地堵住漏洞。带有代码不需要的权限会导致安全问题。

将代码所接收的权限限制为最少特权的另一个方式是列出要拒绝的特定权限。如果您要求所有权限都是可选的,并从该请求中排除特定权限,那么权限通常会被拒绝。

返回页首

确保状态数据的安全

处理敏感数据或作出任何安全决定的应用程序需要使该数据处于自己的控制下,并且不能让其他的潜在恶意代码直接访问该数据。使数据安全地保留在内存中的最佳方式是将其定义为私有或内部(限制在同一程序集的范围内)变量。但是,此数据也服从于您应当知道的访问权:

在反射时,引用了对象的高度受信任代码可以获得并设置私有成员。

使用序列化时,如果高度受信任的代码可以通过对象的序列化形式访问相应数据,那么它就可以有效地获得和设置私有成员。

在调试时,可以读取该数据。

确保自己的任何方法或属性都没有无意地公开这些值。

在某些情况下,数据可以使用“protected”加以保护,这时,只能访问该类及其派生类。但是,由于存在其他公开的可能性,您还应当采取下面的预防措施:

通过将类的派生限制在同一个程序集内,或使用声明性安全来要求某些标识或权限以便从您的类中派生,从而控制允许哪些代码从您的类中派生(请参阅本文的确保方法访问的安全部分)。

确保所有派生类都实现了相似的保护或者被密封。

装箱的值类型

如果您认为已经分发了无法修改原始定义的类型的副本,有时还可以修改装箱的值类型。返回装箱的值类型时,您所返回的是对值类型的引用,而不是对值类型副本的引用,因而允许调用您代码的代码修改您的变量值。

以下 C# 代码示例显示了如何使用引用来修改装箱的值类型。

using System;  
using System.Reflection;  
using System.Reflection.Emit;
using System.Threading;  
using System.Collections; 
class bug {
 // Suppose you have an API element that exposes a 
 // field through a property with only a get accessor.
 public object m_Property;
 public Object Property {
   get { return m_Property;}
   set {m_Property = value;} // (if applicable)
 }
 // You can modify the value of this by doing 
 // the byref method with this signature.
 public static void m1( ref int j ) {
   j = Int32.MaxValue;
 }
public static void m2( ref ArrayList j )
 {
  j = new ArrayList();
 }
 public static void Main(String[] args)
 {
  Console.WriteLine( "////// doing this with value type" );
  {
    bug b = new bug();
    b.m_Property = 4;
    Object[] objArr = new Object[]{b.Property};
    Console.WriteLine( b.m_Property );
    typeof(bug).GetMethod( "m1" ).Invoke( null, objArr );
    // Note that the property changed.
    Console.WriteLine( b.m_Property ); 
    Console.WriteLine( objArr[0] );
  }
  Console.WriteLine( "////// doing this with a normal type" );
  {
    bug b = new bug();
    ArrayList al = new ArrayList();
    al.Add("elem");
    b.m_Property = al;
    Object[] objArr = new Object[]{b.Property};
    Console.WriteLine( ((ArrayList)(b.m_Property)).Count );
    typeof(bug).GetMethod( "m2" ).Invoke( null, objArr );
    // Note that the property does not change.
    Console.WriteLine( ((ArrayList)(b.m_Property)).Count ); 
    Console.WriteLine( ((ArrayList)(objArr[0])).Count );
  }
 }
}

返回页首

确保方法访问的安全

某些方法可能不适合由不受信任的任意代码调用它们。此类方法会导致几个风险:方法可能会提供某些受限制信息;可能会相信传递给它的任何信息;可能不会对参数进行错误检查;或者,如果参数错误,可能会出现故障或执行某些有害操作。您应当注意这些情况,并采取适当的操作来确保方法的安全。

在某些情况下,您可能需要限制不打算公开使用、但仍必须是公共的方法。例如,您可能有一个需要在自己的 DLL 之间进行调用的接口,因此它必须是公共的,但您不想公开它,以防止用户使用它或防止恶意代码利用它作为入口点进入到您的组件中。对不打算公共使用(但仍必须是公共)的方法进行限制的另一个常见理由是,避免用文档记录和支持非常内部的接口。

托管代码为限制方法访问提供了几个方式:

将可访问性的作用域限制到类、程序集或派生类(如果它们是可信任的)。这是限制方法访问的最简单方式。请注意,通常派生类的可信赖度比它们派生自的类更低,但在某些情况下,它们可以共享超类标识。特别是,不要从关键字 protected 推断信任情况,因为在安全上下文中,该关键字不是必须使用的。

将方法访问限制到指定标识(实质上,是您选择的任何特殊证据)的调用方。

将方法访问限制到拥有您所选权限的调用方。

同样,声明性安全也允许您控制类的继承。您可以使用 InheritanceDemand 执行以下操作:

要求派生类拥有指定的标识或权限。

要求重写特定方法的派生类拥有指定的标识或权限。

示例:保护对类或方法的访问

以下示例显示了如何确保公共方法的安全,以限制访问。

sn -k 命令用于创建新的私钥/公钥对。私钥部分需要使用强名称签署代码,并由代码发行者安全地保留。(如果泄露,任何人都可以在他们的代码上假冒您的签名,从而使保护措施失效。)

通过强名称标识来确保方法的安全

sn -k keypair.dat
csc/r:App1.dll /a.keyfile:keypair.dat App1.cs
sn -p keypair.dat public.dat
sn -tp public.dat >publichex.txt
 
[StrongNameIdentityPermissionAttribute 
 (SecurityAction.LinkDemand,
  PublicKey="_",hex_",Name="App1",
  Version="0.0.0.0")]
public class Class1

csc 命令用于编译和签署 App1,以授于它访问受保护方法的权限。

后面的两个 sn 命令用于从密钥对中提取公钥部分,并将它格式化为十六进制。

示例的下半部分是摘录的受保护方法的源代码。自定义属性可定义强名称,指定密钥对中的公钥,并插入从 sn 得到的十六进制格式的数据作为 PublicKey 属性。

在运行时,由于 App1 拥有必需的强名称签名,所以被允许使用 Class1

该示例使用 LinkDemand 来保护 API 元素;有关使用 LinkDemand 的限制的重要信息,请参阅本文后面的部分。

防止不受信任的代码使用类和方法

使用以下声明可以防止部分信任的代码使用类和方法(包括属性和事件)。通过将这些声明应用于类,可以对该类的所有方法、属性和事件应用保护;但请注意,字段访问不会受到声明性安全的影响。请注意,链接请求只保护直接调用方,并且仍可能会受到引诱攻击(本文的基于证据的安全性和代码访问安全性部分对此进行了描述)。

具有强名称的程序集将声明性安全应用于所有可公开访问的方法、属性和事件,因此只有完全受信任的调用方才能使用它们,除非程序集通过应用 AllowPartiallyTrustedCallers 属性显式地决定参与使用。因此,通过显式地标记类来排除不受信任的调用方,只对未签名的程序集或具有该属性的程序集、以及原本就不打算用于不受信任调用方的类型的子集才是必需的。有关全部详细信息,请参阅 Microsoft .NET 框架的第 1 版安全更改文档。

对于公共的非密封类:

[System.Security.Permissions.PermissionSetAttribute(System.Security.
  Permissions.SecurityAction.InheritanceDemand, Name="FullTrust")]
[System.Security.Permissions.PermissionSetAttribute
  (System.Security.Permissions.SecurityAction.LinkDemand, Name="FullTrust")]
public class CanDeriveFromMe

对于公共的密封类:

[System.Security.Permissions.PermissionSetAttribute
  (System.Security.Permissions.SecurityAction.LinkDemand, Name="FullTrust")]
public sealed class CannotDeriveFromMe

对于公共的抽象类:

[System.Security.Permissions.PermissionSetAttribute
  (System.Security.Permissions.SecurityAction.InheritanceDemand, Name="FullTrust")]
[System.Security.Permissions.PermissionSetAttribute
  (System.Security.Permissions.SecurityAction.LinkDemand, Name="FullTrust")]
public abstract class CannotCreateInstanceOfMe_CanCastToMe

对于公共的虚拟函数:

class Base {
[System.Security.Permissions.PermissionSetAttribute
  (System.Security.Permissions.SecurityAction.InheritanceDemand, 
  Name="FullTrust")]
[System.Security.Permissions.PermissionSetAttribute(
  System.Security.Permissions.SecurityAction.LinkDemand, 
  Name="FullTrust")]
public override void CanOverrideOrCallMe() { ... }

对于公共的抽象函数:

class Base {
[System.Security.Permissions.PermissionSetAttribute
  (System.Security.Permissions.SecurityAction.InheritanceDemand, Name="FullTrust")]
[System.Security.Permissions.PermissionSetAttribute
  (System.Security.Permissions.SecurityAction.LinkDemand, 
  Name="FullTrust")]
public override void CanOverrideMe() { ... }

对于基函数不需要完全信任的公共重写函数:

class Derived {
[System.Security.Permissions.PermissionSetAttribute
(System.Security.Permissions.SecurityAction.Demand, Name="FullTrust")]
public override void CanOverrideOrCallMe() { ... }

对于基函数需要完全信任的公共重写函数:

class Derived {
[System.Security.Permissions.PermissionSetAttribute
  (System.Security.Permissions.SecurityAction.LinkDemand,
  Name="FullTrust")]
public override void CanOverrideOrCallMe() { ... }

对于公共接口:

[System.Security.Permissions.PermissionSetAttribute
  (System.Security.Permissions.SecurityAction.InheritanceDemand, 
  Name="FullTrust")]
[System.Security.Permissions.PermissionSetAttribute
  (System.Security.Permissions.SecurityAction.LinkDemand, 
  Name="FullTrust")]
public interface CanCastToMe

Demand LinkDemand

声明性安全提供了两种类似的、但检查方式大不相同的安全检查。花些时间了解这两种形式是值得的,因为错误的选择会导致脆弱的安全性或性能损失。本部分并不打算完整地说明这些功能;有关完整的详细信息,请参阅产品文档。

声明性安全可提供以下安全检查:

Demand 指定代码访问安全性的堆栈审核:堆栈上的所有调用方都必须拥有权限或标识才能通过。Demand 会发生在每个调用上,这是因为堆栈可能包含不同的调用方。如果您重复调用某个方法,则每次都会进行该安全检查。Demand 对引诱攻击具有强大的抵御能力;它可捕获试图通过它的未经授权的代码。

LinkDemand 发生在实时 (JIT) 编译时(在前面的示例中,当引用 Class1 App1 代码将要执行时),并且它只检查直接调用方。该安全检查不检查调用方的调用方。一旦通过该检查,无论它被调用多少次,都不会有其他安全开销。但是,它无法防御引诱攻击。如果使用 LinkDemand,则您的接口是安全的,但通过测试并且可以引用您的代码的任何代码都可以潜在地破坏安全性,因为它们允许使用授权代码调用恶意代码。因此,除非可以完全避免所有可能的弱点,否则不要使用 LinkDemand

使用 LinkDemand 时所需的额外预防措施必须是手工制订的(安全系统可以帮助实施)。任何错误都会导致安全漏洞。使用您代码的所有经授权代码都必须通过执行以下操作,来负责实现其他安全性:

将呼叫代码的访问权限制到类或程序集。

为该代码设置相同的安全检查,并强制它的调用方这样做。例如,如果您编写的代码调用了某个方法,而该方法通过对 SecurityPermission.UnmanagedCode 权限使用 LinkDemand 获得保护,那么您的方法也应当对该权限使用 LinkDemand(或 Demand,这是更强的手段)。例外情况是,假如代码中有其他安全保护机制(例如,Demand),则您的代码将以一种总是安全的或您决定是安全的受限方式使用 LinkDemand 所保护的方法。在调用方负责削弱对基础代码的安全保护情况下,会出现此例外情况。

确保它的调用方无法进行欺骗,以代表它去调用受保护的代码(也就是说,调用方不能强迫经授权的代码将特定参数传递给受保护的代码,或从中获得结果)。

接口和 LinkDemands

如果具有 LinkDemand 的虚拟方法、属性或事件重写了某个基类方法,则此基类方法也必须具有相同的 LinkDemand,以便重写方法也是安全的。恶意代码可能会强制转换回基础类型,并调用基类方法。还要注意,可以将 LinkDemands 隐式地添加到不具有 AllowPartiallyTrustedCallersAttribute 程序集级属性的程序集中。

好的做法是,当接口方法也有 LinkDemands 时,使用 LinkDemands 对方法实现进行保护。

关于对接口使用 LinkDemands,请注意以下事项:

AllowPartiallyTrustedCallers 属性会影响接口。

可以将 LinkDemands 放在接口上,以有选择地挑出某些接口,使其不能由部分信任的代码使用(例如,在使用 AllowPartiallyTrustedCallers 属性时)。

如果在不包含 AllowPartiallyTrustedCallers 属性的程序集中定义接口,则可以在部分信任的类上实现该接口。

如果将 LinkDemand 放在一个实现接口方法的类的公共方法上,在随后强制转换到该接口并调用该方法时,将不会执行 LinkDemand。在这种情况下,因为是对接口进行链接,所以只考虑接口上的 LinkDemand

应当审阅以下各项是否有安全问题:

接口方法上的显式链接请求。确保这些链接请求提供了预期的保护。确定恶意代码是否可以使用强制转换来避开前面描述的链接请求。

具有链接请求的虚拟方法。

它们实现的类型和接口应当一致地使用 LinkDemands

虚拟内部重写

在确认代码对其他程序集不可用时,需要了解类型系统可访问性的细微差别。声明 virtual internal 的方法可以重写超类的 vtable 条目,并且只能在同一个程序集的内部使用,因为它是内部的。但是,重写的可访问性是由 virtual 关键字决定的,并且只要代码能够访问类本身,就可以从另一个程序集对该可访问性进行重写。如果重写的可能性比较小,请使用声明性安全解决它,或者删除 virtual 关键字(如果它不是必需的)。

返回页首

包装程序代码

包装程序代码(特别是在包装程序比使用它的代码具有更高可信度时)可以显露一组独特的安全漏洞。如果没有将调用方的受限制权限包括在适当的安全检查中,则代表调用方所执行的任何操作都是可能被利用的潜在漏洞。

不要通过包装程序来启用调用方本身无法执行的某些操作。在执行某些涉及受限制安全检查的操作时(与完整的堆栈审核请求相反),会有特殊的危险性。涉及到单一级别的检查时,在实际调用方与可疑 API 元素之间插入包装程序代码,可以很容易地使安全检查在不应当成功时成功通过,从而降低了安全性。

委托

无论何时,如果您的代码从可能调用它、但信任度较低的代码那里取得委托权,请确保您不会让信任度较低的代码提升它的权限。如果您取得委托权并随后使用它,那么,如果委托中或委托下面的代码试图执行受保护的操作,则创建委托的代码将不会在调用堆栈中,并且不会测试它的权限。如果您的代码和委托代码具有比调用方更高的特权,这将使调用方能够在不成为调用堆栈一部分的情况下改变调用路径。

要解决该问题,可以限制调用方(例如,要求它具有某个权限)或对执行委托的权限加以限制(例如,通过使用 Deny PermitOnly 堆栈重写)。

LinkDemands 和包装程序

在安全基础结构中,已经加强了对链接请求的特殊保护措施,但它仍然是代码中可能的漏洞来源。

如果完全受信任的代码调用某个由 LinkDemand 保护的属性、事件或方法,那么,如果对调用方的 LinkDemand 权限检查获得通过,则调用将成功。此外,如果完全受信任的代码所公开的类使用了某个属性的名称,并使用反射来调用该属性的 get 访问器,那么,即使用户代码无权访问该属性,对 get 访问器的调用也会成功。这是因为 LinkDemand 将只检查直接调用方,而直接调用方是完全受信任的代码。其实,完全受信任的代码在代表用户代码执行经授权的调用时,不需要确保用户代码有权进行该调用。如果您要包装反射功能,请参阅 Microsoft .NET 框架的第 1 版安全更改一文,以获得详细信息。

为了防止因疏忽造成的安全漏洞(例如,上面描述的情况),运行时将对任何使用调用的操作(实例创建、方法调用、属性设置或获得)所进行的完整堆栈审核请求检查扩展到由链接请求保护的方法、构造函数、属性或事件。该保护会导致一些性能成本(单级 LinkDemand 速度更快),并且会更改安全检查的语义即使单级检查已通过,完整堆栈审核请求也可能会失败。

程序集加载包装程序

用来加载托管代码的几个方法(包括 Assembly.Load(byte[])),可使用调用方的证据加载程序集。具体来说,如果要包装这些方法中的任意一个,安全系统可以使用您代码被授予的权限(而不是调用方对包装程序的权限)来加载程序集。显然,您并不想允许信任度较低的代码指使您代表它加载其所获权限比调用方对包装程序的权限更高的代码。

拥有完全信任或信任度比潜在调用方(包括 Internet 权限级调用方)高得多的任何代码很容易通过这种方式降低安全性。如果您代码包含的公共方法可以取得字节数组并将其传递给 Assembly.Load(byte[]),从而代表调用方创建程序集,则可能会破坏安全性。

该问题会发生在以下 API 元素上:

System.AppDomain.DefineDynamicAssembly

System.Reflection.Assembly.LoadFrom

System.Reflection.Assembly.Load

异常处理

在运行任何 finally 语句之前,将运行堆栈上面的一个筛选表达式。在运行 finally 语句之后,将运行与该筛选器关联的 catch 代码块。请考虑以下伪代码:

void Main() {
    try {
        Sub();
    } except (Filter()) {
        Console.WriteLine("catch");
    }
}
bool Filter () {
    Console.WriteLine("filter");
    return true;
}
void Sub() {
    try {
        Console.WriteLine("throw");
        throw new Exception();
    } finally {
        Console.WriteLine("finally");
    }
}                      

该代码将打印以下内容:

Throw
Filter
Finally
Catch

筛选器将在 finally 语句之前运行,这样,如果可以执行其他代码,则造成状态更改的任何操作都可以带来安全问题。例如:

try {
     Alter_Security_State();
     // This means changing anything (state variables,
     // switching unmanaged context, impersonation, and so on)
     // that could be exploitable if malicious code ran before state is restored.
     Do_some_work();
     } 
finally {
         Restore_Security_State();
         // This simply restores the state change above.
        }

该伪代码允许筛选器备份堆栈以运行任意代码。具有类似效果的其他操作示例是对另一个标识的临时模拟,这样会设置一个避开某个安全检查的内部标志,并更改与线程关联的区域性等等。

建议的解决方案是引入异常处理程序,将代码的线程状态更改与调用方的筛选器块隔离开。但重要的是,要正确引入异常处理程序,否则将无法解决该问题。下面的 Microsoft Visual Basic(R) 示例将切换 UI 区域性,但可以类似地公开任何线程状态更改。

YourObject.YourMethod()
{
   CultureInfo saveCulture = Thread.CurrentThread.CurrentUICulture;
   try {
      Thread.CurrentThread.CurrentUICulture = new CultureInfo("de-DE");
      // Do something that throws an exception.
}
   finally {
      Thread.CurrentThread.CurrentUICulture = saveCulture;
   }
}
 
Public Class UserCode
   Public Shared Sub Main()
      Try
         Dim obj As YourObject = new YourObject
         obj.YourMethod()
      Catch e As Exception When FilterFunc
         Console.WriteLine("An error occurred: '{0}'", e)
         Console.WriteLine("Current Culture: {0}", 
Thread.CurrentThread.CurrentUICulture)
      End Try
   End Sub
 
   Public Function FilterFunc As Boolean
      Console.WriteLine("Current Culture: {0}", Thread.CurrentThread.CurrentUICulture)
      Return True
   End Sub
 
End Class

在此例中,正确的修复措施是在 try/catch 块中包装现有的 try/finally 块。只将 catch-throw 子句引入现有的 try/finally 块中将无法解决问题:

YourObject.YourMethod()
{
   CultureInfo saveCulture = Thread.CurrentThread.CurrentUICulture;
 
   try {
      Thread.CurrentThread.CurrentUICulture = new CultureInfo("de-DE");
      // Do something that throws an exception.
}
catch { throw; }
   finally {
      Thread.CurrentThread.CurrentUICulture = saveCulture;
   }
}

这不会解决问题,因为在 FilterFunc 获得控制权之前尚未运行 finally 语句。

以下代码通过确保在根据调用方的异常筛选块提供异常之前执行 finally 子句,来解决该问题。

YourObject.YourMethod()
{
   CultureInfo saveCulture = Thread.CurrentThread.CurrentUICulture;
   try {
      try {
         Thread.CurrentThread.CurrentUICulture = new CultureInfo("de-DE");
         // Do something that throws an exception.
}
      finally {
         Thread.CurrentThread.CurrentUICulture = saveCulture;
      }
   }
   catch { throw; }
}

返回页首

非托管代码

某些库代码需要调用非托管代码(例如,本机代码 API,如 Win32)。因为这意味着脱离托管代码的安全边界,所以应十分谨慎。如果您的代码是安全中立的(请参阅本文的安全中立代码部分),则您的代码和任何调用它的代码都必须拥有非托管代码权限 (SecurityPermission.UnmanagedCode)

但是,要求您的调用方拥有这么大的权限通常是不合理的。在这种情况下,您的受信任代码可以是调解者,类似于先前描述的托管包装程序或库代码。如果基础非托管代码的功能是完全安全的,则可以直接公开它;否则,需要先进行适当的权限检查 (demand)

当您的代码调用非托管代码、但您不想让调用方拥有该权限时,必须断言您的权利。断言会在您的帧处阻塞堆栈审核。您必须谨慎小心,不要在该过程中创建安全漏洞。通常,这意味着您必须请求适当的调用方权限,然后只使用非托管代码来执行该权限允许的操作,但不能再执行其他操作。在某些情况下(例如,获得一天中的时间),非托管代码可以直接公开给调用方,而不需要进行任何安全检查。在任何情况下,作出断言的任何代码都必须为安全性负责

因为在本机代码中提供代码路径的任何托管代码都是恶意代码的潜在目标,因此确定可以安全地使用什么非托管代码以及必须如何使用它就需要非常小心。通常,所有非托管代码都不应当直接公开给部分信任的调用方(请参阅下一部分)。在对可由部分信任代码调用的库中的非托管代码使用情况的安全性进行评估时,有两个主要注意事项:

功能。非托管 API 是否提供了安全的功能,即,不允许通过调用它来执行潜在危险的操作?代码访问安全性使用权限来实施对资源的访问,所以请考虑 API 是否使用文件、用户界面、线程,或者是否公开受保护的信息。如果是这样,则包装它的托管代码必须请求所需的权限,然后才允许输入它。此外,虽然不受权限保护,但安全性要求将内存访问限制在严格的类型安全范围内。

参数检查。常见攻击在试图使被公开的非托管代码 API 方法进行脱离规范的操作时,会将意外的参数传递给它们。缓冲溢出是此类攻击的一个常见示例(使用范围外的索引或偏移量值),另一个示例是可能利用基础代码中的错误的任何参数。因此,即使非托管代码 API 在功能上对于部分受信任的调用方是安全的(在必要的请求之后),但托管代码仍必须彻底地检查参数的有效性,以使用托管代码包装程序层来确保恶意代码不会发出非预期的调用。

使用 SuppressUnmanagedCodeSecurity

断言然后调用非托管代码有性能方面因素。对每个这样的调用来说,安全系统会自动请求非托管代码权限,这会导致每次都进行堆栈审核。如果您断言并直接调用非托管代码,则堆栈审核没有任何意义:它由断言和非托管代码调用组成。

可以将一个名为 SuppressUnmanagedCodeSecurity 的自定义属性应用于非托管代码入口点,以禁用请求 SecurityPermission.UnmanagedCode 的正常安全检查。这样做时必须始终保持谨慎,这是因为该操作会为在没有运行时安全检查的情况下进入非托管代码创建一扇打开的门。应当注意到,即使应用了 SuppressUnmanagedCodeSecurity,在 JIT 时也会发生一次性安全检查,以确保直接调用方拥有调用非托管代码的权限。

如果您使用 SuppressUnmanagedCodeSecurity 属性,请检查以下几点:

使非托管代码入口点在代码外部不可访问(例如,“internal”)。

调用非托管代码的任何位置都是潜在的安全漏洞。确保您的代码不是恶意代码间接调用非托管代码并避开安全检查的门户。在适当的情况下请求权限。

在创建到非托管代码的危险路径时,使用命名约定使它成为显式的,下一部分将对此进行描述。

非托管代码方法的命名约定

人们已经为命名非托管代码方法建立了有用和高度推荐的约定。所有非托管代码方法都可以划分到三个类别中:safenative unsafe。这些关键字可用作在其中定义各种非托管代码入口点的类名称。在源代码中,这些关键字应添加到类名称中;例如,Safe.GetTimeOfDayNative.Xyz Unsafe.DangerousAPI。这些类别中的每一个都应向使用它们的开发人员发送强消息,如下表所述。

关键字

安全注意事项

safe

对于要调用的任何代码(甚至是恶意代码)完全无害。可以像其他托管代码一样使用。示例:获得一天中的时间。

native

安全中立;即,需要非托管代码权限才能调用的非托管代码。将检查安全性,这将阻止未经授权的调用方。

unsafe

忽略安全且具有潜在危险性的非托管代码入口点。开发人员在使用这种不安全代码时应十分谨慎,同时应确保其他保护措施已就位,以避免安全漏洞。当该关键字重写安全系统时,开发人员必须完全负责。

返回页首

用户输入

用户数据是指任何类型的输入(来自 Web 请求或 URL 的数据、对 Microsoft Windows 窗体应用程序控件的输入等等),它可以对代码造成负面影响,因为这些数据通常被直接用作调用其他代码的参数。这种情况与恶意代码使用陌生参数调用您的代码相似,应当采取相同的预防措施。用户输入实际上更难保证安全,这是因为没有堆栈帧对出现的潜在不受信任数据进行跟踪。

在需要查找的安全性错误中,这些错误是最细微和最难找到的,这是因为尽管它们可以存在于表面上与安全无关的代码中,但它们却是将有问题的数据传递给其他代码的通路。要找到这些错误,请跟踪任何类型的输入数据,想象可能值的范围,并考虑查看该数据的代码是否可以处理所有这些情况。您可以通过范围检查和拒绝代码无法处理的所有输入来修复这些错误。

涉及用户数据的某些常见错误包括:

在客户端上,服务器响应中的所有用户数据都会运行在服务器站点的上下文中。如果 Web 服务器取得用户数据并将它插入返回的 Web 页中,则它可能(例如)包括 <script> 标记,并且运行时好像来自服务器。

请记住,客户端可以请求任何 URL

考虑那些复杂或无效的路径:

../,非常长的路径。

使用通配符 (*)

令牌扩展 (%token%)

具有特殊含义的陌生格式的路径。

备用的 NTFS 流名称;例如,filename::$DATA

文件名的缩写版本;例如,longfilename longfi~1

Eval(userdata) 可以执行任何操作。

对包含某些用户数据的名称的晚期绑定。

如果要处理 Web 数据,您必须考虑许可的各种形式的转义,包括:

十六进制转义 (%nn)

Unicode 转义 (%nnn)

超长的 UTF-8 转义 (%nn%nn)

双重转义(%nn 变成 %mmnn,其中,%mm “%”的转义)。

警惕可能具有多个规范格式的用户名。例如在 Microsoft Windows 2000 中,通常可以使用 REDMOND/用户名 格式或用户名@redmond.microsoft.com 格式。

返回页首

远程处理注意事项

远程处理允许您在应用程序域、进程或机器之间设置透明的调用。但是,代码访问安全性堆栈审核无法跨越进程或机器边界(它只能应用在同一进程的应用程序域之间)。

可远程处理的任何类(从 MarshalByRefObject 类派生的)都需要为安全性负责。要么只在呼叫代码可以被隐式信任的安全封闭环境中使用代码,要么对远程调用进行设计,以便它们不会将受保护的代码公开给可以被恶意使用的外部入口。

通常,您不应公开由声明性的 LinkDemand InheritanceDemand 安全检查所保护的方法、属性或事件。若使用远程处理,则不会执行这些检查。其他安全检查(例如 DemandAssert 等)在进程内的应用程序域之间工作,但不能跨进程或跨机器工作。

返回页首

受保护的对象

某些对象自身可保持安全状态。您不应将这些对象传递给不受信任的代码,如果这样,后者会获得超越它自身权限的安全授权。

以创建一个 FileStream 对象为例。在创建该对象时需要 FileIOPermission,如果成功,将返回文件对象。但是,如果将该对象引用传递给没有文件权限的代码,则该对象将能够读/写此特定文件。

对此类对象的最简单防御措施是,通过公共 API 元素要求试图获得该对象引用的所有代码都拥有相同的 FileIOPermission

返回页首

序列化

使用序列化可以允许其他代码看到或修改以其他方式不可访问的对象实例数据。同样,执行序列化的代码也需要拥有特殊的权限:SecurityPermission.SerializationFormatter。在默认策略下,该权限不会授予从 Internet 下载的代码或 Intranet 代码;只有本地机器上的代码才会被授予该权限。

正常情况下,对象实例的所有字段均被序列化,这意味着数据将以实例的序列化数据形式表示。这样,代码就可以解释该格式以确定数据值是什么,这与成员的可访问性无关。同样,反序列化将从序列化表现形式中提取数据并直接设置对象状态,这同样也与可访问性规则无关。

对于可以包含安全敏感数据的任何对象来说,应使该对象不可序列化(如果可能)。如果它必须是可序列化的,请尝试使包含敏感数据的特定字段不可序列化。如果无法这样做,则应知道该数据将被公开给有权进行序列化的任何代码,并确保没有恶意代码可以获得该权限。

ISerializable 接口的原定用途是仅供序列化基础结构使用。但是,如果未受保护,它也会潜在地泄漏敏感信息。如果通过实现 ISerializable 来提供自定义序列化,则应确保采取以下预防措施:

应通过请求 SecurityPermission.SerializationFormatter 权限,或者确保方法输出中未包含敏感信息,来显式地确保 GetObjectData 的安全。例如:

[SecurityPermissionAttribute(SecurityAction.Demand,SerializationFormatter 
=true)]
public override void GetObjectData(SerializationInfo info, 
StreamingContext context)

用于序列化的特殊构造函数还应执行彻底的输入验证,并且应当受保护或是私有的,以避免被恶意代码滥用。它应当实施相同的安全检查以及通过其他手段(通过某类工厂显式创建或间接创建)获得该类的实例所需的权限。

返回页首

应用程序域跨域问题

要在托管宿主环境中隔离代码,常用方法是:使用降低各种程序集权限级别的显式策略,来创建多个子应用程序域。但是,在默认的应用程序域中,这些程序集的策略保持不变。如果其中一个子应用程序域可以强制默认应用程序域加载程序集,这就会失去代码隔离的效果,并且这些程序集中的类型将能够以更高的信任级别运行代码。

一个应用程序域可以强制另一个应用程序域加载某个程序集,并通过调用驻留在其他应用程序域中的对象的代理来运行所包含的代码。要获得跨应用程序域代理,宿主该对象的应用程序域必须提供一个代理(通过一个方法调用参数或返回值),或者,如果该应用程序域刚刚创建,则创建者将拥有 AppDomain 对象的代理。因此,要避免破坏代码隔离,具有较高信任级别的应用程序域不应将对其域中的 MarshalByRefObject 对象的引用提供给具有较低信任级别的应用程序域。

通常,默认应用程序域在创建子应用程序域时会在每一个子域中包含一个控件对象。控件对象可管理新的应用程序域,并且有时会从默认应用程序域获取命令,但实际上它不并知道如何直接与域联系。有时,默认应用程序域将调用它的控件对象代理。但是,有时可能需要控件对象能够回调到默认应用程序域。在这些情况下,默认应用程序域会将按引用封送的回调对象传递给控件对象的构造函数。控件对象负责保护该代理。如果控件对象打算将代理放到公共类的公共静态字段上,或者公开地公开该代理,那么这会为其他代码回调到默认应用程序域创造一个危险的机制。因此,控件对象始终被隐式信任,以保持代理的私有性。

返回页首

评估权限

基于证据的安全性的前提条件是,只有可信赖的代码才会被授予高度信任(许多强大的权限),而恶意代码只会被授予很少的信任或者不授予信任。.NET 框架随附的默认策略使用区域(由 Microsoft Internet Explorer 确定)来授予权限。下面是默认策略的简要说明:

本地机器区域(例如,c:/app.exe)接收完全信任。它假定用户仅将他们信任的代码放在机器上,并且大多数用户不想以不同的信任区域来隔离他们的硬盘。该代码基本上可以做任何事情,并且托管代码安全性是无法实施的,所以没有办法防御该区域中的恶意代码。

Internet 区域(例如,http://www.microsoft.com/)代码被授予一组非常有限的权限,这些权限通常被认为可以安全地授予,甚至可以授予恶意代码。一般情况下,不可信任该代码,所以只能使用一组它无法用来执行有害操作的弱权限来安全地执行,这组权限是:

WebPermission。对它所在站点服务器的访问权。

FileDialogPermission。只能访问用户明确选择的文件。

IsolatedStorageFilePermission。由 Web 站点隔离的永久存储区。

UIPermission。可以在包含 UI 的安全窗口中执行写入操作。

Intranet 区域(例如,//UNC/share)代码被授予一组略强的 Internet 权限,但仍然没有强大的权限:

FileIOPermission。对它所在目录中文件的只读权限。

WebPermission。对它所在服务器的访问权。

DNSPermission。允许 DNS 名称被解析为 IP 地址。

FileDialogPermission。只能访问用户明确选择的文件。

IsolatedStorageFilePermission。限制更少的永久存储区。

UIPermission。可以自由使用它自己的顶层窗口。

受限制的站点区域代码只被授予最低的执行权限。

您应考虑自己的安全需要,并适当地修改安全策略。不可能只用一个安全配置来满足所有需要:默认策略的原定用途是,在通常情况下不允许出现有危险的任何事情。

您的代码将接收不同的权限,这取决于它是如何部署的。确保代码将被授予能够正确操作的足够权限。在考虑确保代码安全以抵御攻击时,请考虑攻击代码的可能来源,以及它访问您代码的可能方式。

危险的权限

.NET 框架为其提供权限的几个受保护操作可以潜在地允许规避安全系统。这些危险的权限应当只授予可信赖的代码,并且只能在需要时使用。如果向恶意代码授予了这些权限,则通常无法抵御恶意代码的攻击。

危险的权限包括:

SecurityPermission

UnmanagedCode。允许托管代码调用非托管代码,这通常是危险的。

SkipVerification。如果不进行验证,代码可以做任何事情。

ControlEvidence。虚构证据来欺骗安全策略。

ControlPolicy。修改安全策略的能力可以破坏安全性。

SerializationFormatter。使用序列化可以规避可访问性(前面已讨论)。

ControlPrincipal。设置当前用户的能力可以欺骗基于角色的安全性。

ControlThread。由于安全状态与线程相关联,因此操纵线程是危险的。

ReflectionPermission

MemberAccess。挫败可访问性机制(可以使用私有成员)。

安全性和争用条件

另一个关注区域涉及由争用条件利用安全漏洞的可能性。这在几种方式下很明显。下面的子节概括了开发人员必须避免的某些主要缺陷。

dispose 方法中的争用条件

如果某个类的 Dispose 方法未同步化,则 Dispose 内部的清除代码可能会运行多次。请考虑使用以下代码:

void Dispose() {
   if( _myObj != null ) {
      Cleanup(_myObj);
      _myObj = null;
   }
}

因为该 Dispose 实现未同步化,所以Cleanup 可能在被第一个线程调用之后又被第二个线程调用,之后才将_myObj 设置为null。这是否是需要关注的安全问题取决于在 Cleanup 代码运行时所发生的事情。未同步化的 Dispose 实现的主要问题涉及对资源句柄(文件等)的使用。不正确的处理可能会导致使用错误的句柄,而这通常会导致安全漏洞。

构造函数中的争用条件

在某些应用程序中,其他线程有可能在类成员的类构造函数完全运行之前访问这些类成员。您应当检查所有类构造函数,以确保在发生这种情况时不会出现安全问题,或者在需要时同步化线程。

缓存对象的争用条件

如果类的其他部分未适当地进行同步化,则缓存安全信息或 Asserts 的代码也可能很容易受到争用条件的侵害。请考虑使用以下代码:

void SomeSecureFunction() {
   if(SomeDemandPasses()) {
      _fCallersOk = true;
      DoOtherWork();
      _fCallersOk = false();
   }
}
void DoOtherWork() {
   if(  _fCallersOK ) {
      DoSomethingTrusted();
   }
   else {
      DemandSomething();
      DoSomethingTrusted();
   }
}

如果可以使用同一对象从另一个线程调用的 DoOtherWork 有其他路径,则不受信任的调用方可以略过某个请求。

如果您的代码缓存了安全信息,请确保检查它是否有该漏洞。

完成器中的争用条件

争用条件的另一个来源是那些引用了它们在完成器中释放的静态或非托管资源的对象。如果多个对象共享在类的完成器中操作的资源,则这些对象必须同步化对该资源的所有访问。

返回页首

其他安全技术

本部分列出了其他一些可能适用于您代码的安全技术,但这里无法充分讨论它们。

On-the-Fly 代码生成

某些库的操作方式是,生成代码并运行它以执行调用方的某些操作。基本问题是,代表信任度较低的代码生成代码,并以较高的信任度运行它。如果调用方可以影响代码生成,那么问题将恶化,因此您必须确保只生成安全代码。

您始终需要确切了解要生成什么代码。这意味着,您必须严格控制从用户那里得到的任何值,注意他们括起来的字符串(它们应当被转义,这样就无法包含意外的代码元素)、标识符(应当检查这些标识符,以验证它们的有效性)或其他任何内容。标识符可能是危险的,因为您可以修改经过编译的程序集,使它的标识符中包含陌生字符,而这些字符将有可能破坏程序集(尽管这通常不是安全漏洞)。

建议您使用 Reflection.Emit 生成代码,这样做通常可以帮助您避免很多这类问题。

编译代码时,请考虑恶意程序是否可以用某种方式修改它。在编译器读取磁盘上的源代码之前或在您的代码加载 DLL 之前,恶意代码是否有机会更改源代码?如果有,则必须根据情况使用代码访问安全性或文件系统中的访问控制列表来保护包含这些文件的目录。

如果调用方可以用使编译器出错的方式影响被生成的代码,则这里也可能存在安全漏洞。

以最低的可能权限设置运行生成的代码(使用 PermitOnly Deny)。

基于角色的安全性:身份验证和授权

除了确保代码的安全以外,某些应用程序还需要实现将使用者范围限制在某些用户或用户组的安全保护。基于角色的安全性(不在本文的讨论范围内)旨在处理这些需要。

处理机密

数据在内存中时可以相当有效地保密,但持久地保存它并保密则很难做到。第一版的 .NET 框架没有为保密处理提供托管代码支持。如果您有专门技术,则加密库可提供许多基本的必需功能。

加密和签名

System.Security.Cryptography 命名空间包含一组丰富的加密算法。安全地实施加密需要某些专门技术,并且不应当以特殊方式尝试。处理所涉及的数据和密钥的每个方面都必须经过仔细设计和检查。加密的细节不在本文的讨论范围内。有关详细信息,请参阅标准的参考资料。

随机数

应当使用 System.Security.Cryptography.RandomNumberGenerator 来生成可能在需要真实随机性的安全操作中使用的任意随机数。使用虚拟随机数生成器会产生能够被利用的可预知性。

安装问题

本部分描述了测试应用程序或组件的安装以确保最佳安全做法并保护已安装代码的注意事项。建议在安装托管代码或非托管代码时采用以下步骤,以确保安装本身的安全。应对所有支持 NTFS 的平台执行这些步骤:

将系统设置为两个分区。

重新格式化第二个分区;不要更改根驱动器上的默认 ACL

安装产品,将安装目录更改为第二个分区上的新目录。

验证以下各项:

是否有任何代码作为服务执行,或者通常由具有全局可写权的管理员级别的用户运行?

代码是否安装在处于应用程序服务器模式下的终端服务器系统上?您的用户现在是否可以写入其他用户可能运行的二进制代码?

在非管理员可能写入的系统区域或系统区域的子目录中,是否有任何内容最终出现在这里?

此外,如果产品需要与 Web 进行交互,则应当知道偶尔使用 Web 服务器将允许用户运行通常在 IUSR_MACHINE 帐户的上下文中执行的命令。某些全局可写的文件或配置项可以在这些条件下被来访者帐户利用,请验证不存在这样的文件或配置项。

返回页首

 

阅读全文
0 0

相关文章推荐

img
取 消
img