msdn 构建安全的数据访问

本单元概要

数据访问是从 ASP.NET Web 应用程序使用几个 ADO.NET 数据提供程序中的一个访问数据库的过程。

数据库是应用程序级攻击的主要目标。攻击者使用应用程序级攻击,以利用您的数据访问代码中的缺陷,获取未授权的数据库访问权限。如果所有其他受攻击区域都关闭了,则应用程序的前门 — 端口 80,将成为攻击者窃取、操纵和破坏数据的路径选择。

本单元说明了如何构建安全的数据访问代码,并避免常见的缺陷和失误。本单元提出了一系列对策和防范技术,可以用在数据访问代码中减轻与数据访问有关的一些最大威胁。

目标

使用本单元可以:

设计、构建和部署安全的数据访问代码。

使用代码访问安全和基于角色的安全限制未授权的调用方或者代码进行访问。

安全地对用户进行身份验证。

防止 SQL 注入攻击。

保护数据库连接字符串。

使用加密保护存储在数据库中的数据。

保护跨网络发送到数据库和来自数据库的数据。

安全地将密码(与带 salt 值的散列值一起)存储在数据库中。

实现安全的异常处理。

了解如何使用代码访问安全以允许中度信任的 Web 应用程序使用 OLE DB、Oracle 和 ODBC 数据提供程序(这些数据提供程序要求完全信任)。

了解可用于应对常见的数据访问威胁(包括 SQL 注入、配置数据的泄漏、敏感应用程序数据的泄漏、数据库架构和连接详细信息的泄漏、未授权的访问和网络侦听)的对策。

适用范围

本单元适用于下列产品和技术:

Microsoft® Windows® Server 2000 和 Windows Server™ 2003 操作系统

Microsoft .NET Framework 1.1 和 ASP.NET 1.1

Microsoft SQL Server™

如何使用本单元

要想充分利用本单元,请在本单元之前或者结合本单元阅读以下单元:

阅读威胁与对策单元。此单元能使您对 Web 应用程序所面临的潜在威胁和对策有更广和更深的理解。

阅读安全 Web 应用程序设计指南单元。此单元中,您将了解构建安全解决方案的体系结构、设计挑战和指导。

阅读“保护数据库服务器”单元阅读此单元可理解如何保护数据库服务器。

阅读构建安全的程序集单元。此单元中有关构建安全的程序集和开发安全托管代码的指导和推荐实践,应该也适用于数据访问代码。

使用评估单元。要在产品生存期的不同阶段审查数据访问的安全,请参考以下单元中的 Web 服务部分:“安全的体系结构和设计审查”、“安全代码审查”和“安全部署审查”。

使用核对表。“核对表:保护数据访问的安全”包括一个易于参考的核对表。可以将这个基于任务的核对表用作本单元中推荐实践的一个总结。

请注意在当前版本的 .NET Framework (1.1) 中,只有 ADO.NET SQL Server 数据访问提供程序支持部分信任调用方,可安全地用于部分信任 Web 应用程序。OLE DB、Oracle 和 ODBC ADO.NET 数据提供程序都要求完全信任。

本页内容
本单元概要本单元概要
目标目标
适用范围适用范围
如何使用本单元如何使用本单元
威胁与对策威胁与对策
设计注意事项设计注意事项
输入验证输入验证
SQL 注入SQL 注入
身份验证身份验证
授权授权
配置管理配置管理
敏感数据敏感数据
异常管理异常管理
构建安全的数据访问组件构建安全的数据访问组件
代码访问安全注意事项代码访问安全注意事项
部署注意事项部署注意事项
小结小结
其他资源其他资源

威胁与对策

要构建安全的数据访问代码,需要了解威胁是什么,数据访问代码中是如何出现常见缺陷的,以及如何使用相应的对策降低风险。

数据访问代码的最大威胁有:

SQL 注入

配置数据的泄漏

敏感应用程序数据的泄漏

数据库架构和连接详细信息的泄漏

未授权访问

网络侦听

图 1 显示了这些最大的威胁。


1. 对数据访问代码的威胁和攻击

SQL 注入

SQL 注入攻击利用有缺陷的数据访问代码,使攻击者可在数据库中执行任意命令。如果应用程序使用在数据库中无约束的帐户,威胁更大,因为这将赋予攻击者更大的执行查询和命令的自由。

缺陷

使您的数据访问代码易受 SQL 注入攻击的常见缺陷包括:

脆弱的输入验证

不使用类型安全的参数动态构造 SQL 语句

过高特权的数据库登录的使用

对策

应对 SQL 注入攻击,要确保:

约束和净化输入数据。

使用类型安全的 SQL 参数进行数据访问。这些参数可用于存储过程或者动态构造的 SQL 命令字符串。参数执行类型和长度检查,并确保注入的代码被视为文本数据,而不是可执行的数据库语句。

使用有数据库受限权限的帐户。理想情况下,您应该只给数据库中经过选择的那些存储过程授予执行权限,而且不要提供直接的表访问权限。

配置数据的泄漏

数据访问代码使用的最敏感的配置数据是数据库连接字符串。如果有安全问题的连接字符串中包括用户名和密码,结果将更严重。

缺陷

以下缺陷将增加与有安全问题的配置数据相关的安全风险:

使用 SQL 身份验证,这要求在连接字符串中指定凭据

在代码中嵌入连接字符串

配置文件中的明文连接字符串

未能加密连接字符串

对策

要防止配置数据的泄漏:

使用 Windows 身份验证,这样连接字符串中不会包含凭据。

加密连接字符串和限制访问加密数据。

敏感应用程序数据的泄漏

许多应用程序存储着敏感的数据,如客户的信用卡号码。保护这类数据的私密性和完整性是非常重要的。

缺陷

会导致敏感应用程序数据泄漏的编码实践包括:

未加密存储数据

脆弱的授权

脆弱的加密

对策

要防止敏感应用程序数据的泄漏:

使用坚固的加密机制保护数据。

在执行数据访问之前授权每个调用方,这样用户只能看到他们自己的数据。

数据库架构和连接详细信息的泄漏

如果您的代码将异常详细信息返回客户端,恶意用户就可使用这些信息攻击服务器。数据访问代码中的异常会暴露敏感的信息,如数据库架构的详细信息、数据存储区的性质和 SQL 代码片段。

缺陷

以下缺陷可能导致信息泄漏:

异常处理不当

脆弱的 ASP.NET 配置,会使未处理的异常详细信息返回客户端

对策

要防止这些泄漏问题:

在您的数据访问代码中捕获、记录和处理数据访问异常。

返回一般性错误消息给调用方。这需要对 Web.config 或者 Machine.config 配置文件中的 <customErrors> 元素进行适当的配置。

未授权访问

如果授权不当,用户可能能够看到另一个用户的数据并可能能够访问其他受限的数据。

缺陷

可能允许未授权访问的实践包括:

数据访问代码中缺乏授权,会提供无限的访问权限

特权过高的数据库帐户

对策

为防止未授权的访问:

使用主体权限要求授权调用方用户。

使用代码访问安全权限要求对调用方代码授权。

使用受限权限限制应用程序登录数据库,并防止直接对表进行访问。

网络侦听

大多数应用程序的部署体系结构都包括一个将数据访问代码从数据库服务器分隔开来的物理隔离层。因此,敏感的数据如特定于应用程序的数据或者数据库登录凭据,必须进行保护,以防网络侦听。

缺陷

以下实践增加了网络侦听的可能:

SQL 身份验证过程中在网络上传递明文凭据

发送到数据库服务器和来自数据库服务器的未加密敏感应用程序数据

对策

为了限制网络侦听缺陷,应该:

使用 Windows 身份验证避免在网络上发送凭据。

在数据库服务器上安装服务器证书。这将使网络上传输的 SQL 凭据自动加密。

在 Web 服务器和数据库服务器之间使用 SSL 连接保护敏感的应用程序数据。这需要使用数据库服务器证书。

在 Web 和数据库服务器之间使用 IPSec 加密信道。

设计注意事项

在开始编写代码之前,有许多重要问题需要在设计时考虑。重要的注意事项有:

使用 Windows 身份验证

使用最低特权帐户

使用存储过程

在存储区中保护敏感的数据

使用不同的数据访问程序集

使用 Windows 身份验证

理想情况下,您的设计应该使用 Windows 身份验证以获得更多安全上的优势。使用 Windows 身份验证,就不必存储带有嵌入凭据的数据库连接字符串,凭据也不会在网络上传递,而且还可从安全的帐户和密码管理策略中获益。但是您的确需要仔细考虑应该使用哪个帐户通过 Windows 身份验证连接 SQL Server。

有关更多信息,请参阅本单元后面的“身份验证”。

使用最低特权帐户

您的应用程序应该使用最低特权帐户,该帐户只有数据库的有限权限。确保应用程序登录数据库经过适当的授权和限制。有关详细信息,请参阅本单元后面的“授权”。

如果您的帐户受到损坏或者注入了恶意代码,使用最低特权帐户可减少风险,限制可能的损害。如果出现 SQL 注入,命令将执行在应用程序登录所定义的安全上下文中,并受到登录具有的数据库相关权限的限制。如果您使用特权过高的帐户(例如,作为 SQL Server sysadmin 角色的成员)连接,攻击者就可对服务器上的任何数据库执行任何操作。这包括插入、更新和删除数据、删除表和执行操作系统命令。

重要说明 不要使用 sa 帐户或者任何属于 SQL Server sysadmin 或者 db_owner 角色成员的帐户连接 SQL Server。

使用存储过程

存储过程在性能、可维护性和安全方面都有其优势。应该尽可能地使用参数化的存储过程进行数据访问。其安全方面的优势包括:

您可以限制应用程序进行数据库登录,使其只有执行指定存储过程的权限。授予直接表访问权限是不必要的。这有助于降低 SQL 注入攻击带来的风险。

对所有传递给存储过程的输入数据执行长度和类型检查。而且,参数不能被视为可执行代码。同样,这也降低了 SQL 注入攻击的风险。

如果您由于某种原因无法使用参数化的存储过程,需要动态构造 SQL 语句,也应该使用类型化参数和参数占位符确保对输入数据进行长度和类型检查。

保护存储区中的敏感数据

确定存储的哪些数据需要保证私密性和完整性。如果您在数据库中存储密码只是为了验证,可以考虑使用单向散列。即使密码表受到损坏,也无法使用散列值获取明文密码。

如果您需要存储敏感的用户提供的数据如信用卡号码,应该使用坚固的对称加密算法(如三重 DES (3DES))加密数据。可以使用 Win32 数据保护 API (DPAPI) 加密 3DES 加密密钥,并将加密的密钥存储在带有受限的 ACL 的只有管理员和您的应用程序进程帐户可使用的注册表项中。

为什么不使用 DPAPI 呢?

虽然推荐将 DPAPI 用于对遇到机器故障时可手工进行还原和重构的连接字符串和其他密文(如帐户凭据)进行加密,但是它并不太适合存储信用卡号码之类的数据。这是因为存在可还原性问题(如果密钥丢失,没有办法还原加密的数据)和 Web 服务器场问题。相反,您应该使用对称加密算法(如 3DES),并使用 DPAPI 加密密钥。

使 DPAPI 不太适合在数据库中存储敏感数据的主要问题可以总结如下:

如果 DPAPI 用于机器密钥,您传递 CRYPTPROTECT_LOCAL_MACHINECryptProtectDataCryptUnprotectData 函数,机器帐户将生成加密密钥。这意味着 Web 服务器场中每个服务器都有不同的密钥,这防止了一个服务器能够访问另一个服务器加密的数据。同样,如果 Web 服务器机器遭到破坏,密钥丢失,则加密的数据将无法从数据库还原。

如果您使用机器密钥方式,则此计算机上的任何用户都可解密数据(除非您还使用其他加密机制)。

如果您将 DPAPI 用于用户密钥,并使用本地用户帐户,每个 Web 服务器上的每个本地帐户都有不同的安全标识符 (SID),并生成不同的密钥,这就防止了一个服务器能够访问另一个服务器加密的数据。

如果您将 DPAPI 用于用户密钥,并在 Web 服务器场中使用跨机器的漫游用户配置文件,则所有数据都将共享相同的加密/解密密钥。但是,如果负责漫游用户配置文件帐户的域控制器遭到破坏,将无法重新创建具有相同的 SID 的用户帐户,而且您无法从数据库中还原加密数据。

同样,使用漫游用户配置文件,如果有人设法检索数据,假如攻击者能够以特定的用户帐户运行代码,则数据可在网络中的任何机器上解密。这增加了潜在的受攻击面,我们不推荐这样做。

使用不同数据访问程序集

如果您能够选择的话,不要将数据访问逻辑直接放在 ASP.NET 页或者代码隐藏文件中。将数据访问逻辑放在不同的程序集,并实现一个逻辑数据访问层(独立于应用程序业务逻辑和表示逻辑)有安全、重用和维护上的优点。

从安全角度来看,您可以:

对程序集使用强名称,这能够提供防篡改功能。

使用沙箱保护隔离您的数据访问代码,这在代码需要支持部分信任调用方(例如,部分信任 Web 应用程序)时非常重要。

使用数据访问方法和类,这些方法和类使用代码标识权限要求对调用方代码授权。

为了进行纵深防范,在您的业务组件中使用主体权限要求执行基于主体的授权,并使用代码标识权限要求对调用您的数据访问逻辑的代码进行授权,如图 2 中所示。


2. 表示层、业务层和数据访问层的分离

有关授权数据访问代码的更多信息,请参阅本单元后面的“授权”部分。

输入验证

除了确保您的数据库保持有效和一致的数据的业务需求之外,您还必须在向数据库提交数据之前对其进行验证,以防止 SQL 注入。如果您的数据访问代码是从当前信任边界内的其他组件收到输入,而且已知数据已经进行了验证(例如,通过 ASP.NET Web 页或者业务组件进行验证),那么您的数据访问代码可以忽略进一步的数据验证。但是,一定要在数据访问代码中使用 SQL 参数。这些参数将验证输入参数的类型和长度。下一部分将讨论 SQL 参数的使用。

SQL 注入

SQL 注入攻击在应用程序使用输入构建动态的 SQL 语句以访问数据库时可能发生。SQL 注入攻击还可能在代码使用一些存储过程(传入的参数有包含未筛选的用户输入)时发生。SQL 注入可能使攻击者能够使用应用程序登录在数据库中执行命令。如果应用程序使用特权过高的帐户连接数据库,问题将更加严重。

传统的安全措施,如 SSL 和 IPSec 的使用,并不会为您防范 SQL 注入攻击。

防止 SQL 注入

可以使用以下对策防止 SQL 注入攻击:

约束输入

使用类型安全的 SQL 参数

约束输入

验证输入的类型、长度、格式和范围。如果输入不可能是数值,那么就不接受数值。考虑输入从何而来。如果它来自已知执行了全面输入验证的可信来源,可以选择在数据访问代码中忽略数据验证。如果数据来自不可信的来源或者为了进行纵深防范,您的数据访问方法和组件都应该验证输入。

使用类型安全的 SQL 参数

SQL 中的 Parameters 集合提供了类型检查和长度验证。如果您使用 Parameters 集合,输入将被视为文本值处理,而且 SQL 不会将它视为可执行代码。使用 Parameters 集合的另一个好处是您可以实施类型和长度检查。如果值超出范围将触发异常。这是纵深防范的一个很好的例子。

重要说明 SSL 并不保护您免受 SQL 注入攻击。任何访问数据库的应用程序,如果没有正确的输入验证和适当的数据访问技术,都容易遭到 SQL 注入攻击。

尽可能地使用存储过程,而且应该通过 Parameters 集合调用它们。

在存储过程中使用参数集合

以下代码片段说明了 Parameters 集合的使用:

SqlDataAdapter myCommand = new SqlDataAdapter("AuthorLogin", conn);
myCommand.SelectCommand.CommandType = CommandType.StoredProcedure;
SqlParameter parm = myCommand.SelectCommand.Parameters.Add(
                       "@au_id", SqlDbType.VarChar, 11);
parm.Value = Login.Text;

在这种情况下,@au_id 参数将被当作文本值而不是可执行代码。同样,对参数将进行类型和长度检查。在上面的示例中,输入值不能长于 11 个字符。如果数据不遵守参数所定义的类型或者长度,将出现异常。

请注意使用存储过程并不一定防止 SQL 注入。重要的是在存储过程中使用参数。如果不使用参数,您的存储过程如果使用未经筛选的输入时,就很容易遭到 SQL 注入攻击。例如,以下代码片段就存在问题:

SqlDataAdapter myCommand = new SqlDataAdapter("LoginStoredProcedure '" + 
                               Login.Text + "'", conn);

重要说明 如果您使用存储过程,一定要使用参数。

在动态 SQL 中使用参数集合

如果无法使用存储过程,您还可以使用参数,如以下代码片段中所示:

SqlDataAdapter myCommand = new SqlDataAdapter(
"SELECT au_lname, au_fname FROM Authors WHERE au_id = @au_id", conn);
SqlParameter parm = myCommand.SelectCommand.Parameters.Add("@au_id", 
                        SqlDbType.VarChar, 11);
parm.Value = Login.Text;

使用参数批处理

有一个常见的误解,如果您将几个 SQL 语句串联起来,在一次往返中发送一批语句给服务器,是无法使用参数的。但是,如果能肯定参数名称不会重复,其实可以使用这个技术。可以通过在 SQL 文本串联的过程中,为每个参数名称添加一个数或者一些其他唯一值来轻易实现这一点。

使用筛选器例程

另一种用来防范 SQL 注入攻击的方式是开发筛选器例程,在对 SQL 有特殊意义的字符中添加转义符,如一个撇号字符。以下代码片段说明了添加转义符的筛选器例程:

private string SafeSqlLiteral(string inputSQL)
{
  return inputSQL.Replace("'", "''");
}

例程会出现问题(如上面的这种问题)和不应完全依赖它们的原因在于,攻击者可使用 ASCII 十六进制字符绕过您的检查。但是,您还是应该对输入进行筛选,这是纵深防范策略的一部分。

不要依赖对输入的筛选。

请注意如果您使用 LIKE 子句,通配符仍然需要使用转义符。以下代码片段说明了这一技术:

s = s.Replace("[", "[[]");
s = s.Replace("%", "[%]");
s = s.Replace("_", "[_]");

身份验证

当您的应用程序与 SQL Server 数据库连接时,可以选择 Windows 身份验证或者 SQL 身份验证。Windows 身份验证安全性更高。如果您必须使用 SQL 身份验证(这可能因为需要使用许多不同的帐户连接数据库而且想避免调用 LogonUser),那么应该采取更多步骤尽可能地降低额外的风险。

使用 LogonUser 创建模拟标记,需要 Microsoft Windows 2000 上强大的“Act as part of operating system”特权,因此应该避免使用这种方式。

考虑以下推荐实践:

使用 Windows 身份验证

保护 SQL 身份验证的凭据

使用最低特权帐户连接

使用 Windows 身份验证

Windows 身份验证不会跨网络发送凭据。如果 Web 应用程序使用 Windows 身份验证,在大多数情况下,应该使用服务帐户或者进程帐户(如 ASPNET 帐户)来连接数据库。Windows 和 SQL Server 必须可识别在数据库服务器上使用的帐户。帐户必须被授予登录 SQL Server 的权限,而且登录需要有访问数据库的相关权限。

使用 Windows 身份验证时,应该使用可信的连接。以下代码片段说明了使用 Windows 身份验证的典型连接字符串。

以下示例使用了 SQL Server 的 ADO.NET 数据提供程序:

SqlConnection pubsConn = new SqlConnection(
   "server=dbserver; database=pubs; Integrated Security=SSPI;");

以下示例使用了 OLE DB 数据源的 ADO.NET 数据提供程序:

OleDbConnection pubsConn = new OleDbConnection(
   "Provider=SQLOLEDB; Data Source=dbserver; Integrated Security=SSPI;" +
   "Initial Catalog=northwind");

保护 SQL 身份验证的凭据

如果您必须使用 SQL 身份验证,应确保凭据不会以明文形式跨网络发送,而且应该加密数据库连接字符串,因为其中包含凭据。

为了使 SQL Server 能够自动加密跨网络发送的凭据,在数据库服务器上安装服务器证书。此外,也可以使用 Web 服务器和数据库服务器之间的 IPSec 加密信道保护所有发送到数据库服务器和来自数据库服务器的流量。要保护连接字符串,可以使用 DPAPI。有关更多信息,请参阅本单元后面的“配置管理”部分中的“保护连接字符串”。

使用最低特权帐户连接

您的应用程序应该通过使用最低特权帐户连接数据库。如果您使用 Windows 身份验证连接,从操作系统的角度来看,Windows 帐户应该具有最低特权,而且应该只有访问 Windows 资源的受限特权和受限能力。此外,无论您是否使用 Windows 身份验证或者 SQL 身份验证,相应的 SQL Server 登录都应该通过数据库中的权限进行限制。

有关如何创建最低特权数据库帐户和使用 Windows 身份验证将 ASP.NET Web 应用程序与远程数据库连接的选项的更多信息,请参阅“保护 ASP.NET 应用程序的安全”单元中的“数据访问”部分。

授权

授权过程确定了用户是否可检索和操作特定的数据。授权有两种方式:数据访问代码可使用授权确定是否执行所请求的操作,数据库可执行授权限制应用程序使用的 SQL 登录的能力。

若授权不当,用户可能能够看到另一个用户的数据,而未授权的用户可能能够访问受限的数据。为了应对这些威胁应该:

限制未授权的调用方

限制未授权的代码

在数据库中限制应用程序

3 总结了应该使用的授权点和技术。


3. 数据访问授权、程序集和数据库

请注意数据访问代码是如何使用权限要求对调用方用户或者调用方代码进行授权的。代码标识要求是 .NET 代码访问安全的一个功能。

要在数据库中授权应用程序,使用最低特权 SQL 服务器登录,该登录帐户只有执行经过选择的存储过程的权限。除非有特殊原因,否则应用程序将无法得到直接对任何表执行创建、检索、更新、破坏/删除 (CRUD) 操作的授权。

存储过程运行在数据库系统的安全上下文之下。虽然可以通过授予特殊的存储过程权限而约束应用程序的逻辑操作,但是您无法约束存储过程所执行的操作的结果。存储过程是可信代码。存储过程的接口必须使用数据库权限进行保护。

限制未授权的调用方

代码应该在用户连接数据库之前根据角色或者标识对其授权。角色检查通常用在应用程序的业务逻辑中,但是如果您没有明确地区分业务和数据访问逻辑,则应该在访问数据库的方法上使用主体权限要求。

以下属性确保了只有是 Manager 角色成员的用户可调用 DisplayCustomerInfo 方法:

 [PrincipalPermissionAttribute(SecurityAction.Demand, Role="Manager")]
public void DisplayCustomerInfo(int CustId)
{
}

如果您需要更细的授权粒度,并且需要在数据访问方法中执行基于角色的逻辑,应该使用命令性的主体权限要求或者显式的角色检查,如以下代码片段中所示:

using System.Security;
using System.Security.Permissions;
public void DisplayCustomerInfo(int CustId)
{
  try
  {
    // Imperative principal permission role check to verify that the caller
    // is a manager
    PrincipalPermission principalPerm = new PrincipalPermission(
                                                   null, "Manager");
    // Code that follows is only executed if the caller is a member
    // of the "Manager" role
  }
  catch( SecurityException ex )
  {
   . . .
  }
}

以下代码片段使用一个显式的、编程实现的角色检查确保调用方是 Manager 角色的成员:

public void DisplayCustomerInfo(int CustId)
{
  if(!Thread.CurrentPrincipal.IsInRole("Manager"))
  {
    . . .
  }
}

限制未授权的代码

通过使用 .NET Framework 代码访问安全 — 说得更具体一些就是代码标识要求,您可以对可访问数据访问类和方法的程序集进行限制。

例如,如果您只想您的公司或者特定的开发单位编写的代码能够使用您的数据访问组件,应该使用一个 StrongNameIdentityPermission 并要求调用方程序集拥有指定公钥的强名称,如以下代码片段中所示:

using System.Security.Permissions;
. . .
[StrongNameIdentityPermission(SecurityAction.LinkDemand, 
                              PublicKey="002...4c6")]
public void GetCustomerInfo(int CustId)
{
}

要提取给定程序集公钥的文本表示,可以使用以下命令:

sn -Tp assembly.dll

在 –Tp 开关中应该使用大写的“T”。

因为 Web 应用程序程序集是动态编译的,您无法使用这些程序集的强名称。这将使限制只有特定的 Web 应用程序可使用数据访问程序集非常困难。最佳方式是开发一个自定义权限并要求数据访问组件的权限。完全信任 Web 应用程序(或者任何完全信任的代码)可调用您的组件。而部分信任代码只有在它被授予自定义权限时,可调用您的数据访问组件。

自定义权限实现的示例,请参阅本指南“如何……”部分中的“如何创建自定义加密权限”。

在数据库中限制应用程序

更好的方式是为 Windows 帐户创建一个 SQL Server 登录供应用程序用来连接数据库。然后将 SQL Server 登录映射为数据库中的数据库用户。将数据库用户置于用户定义的数据库角色,并授予该角色权限。理想情况下,您应该只授予该角色应用程序所用的存储过程的执行访问权限。

有关如何配置这种方式的详细信息,请参阅“保护 ASP.NET 应用程序的安全”单元中的“为您的 ASP.NET 应用程序配置数据访问”。

配置管理

数据库连接字符串是数据访问代码主要的配置管理关注点。应该仔细考虑将这些字符串存储在哪里,如何进行保护,特别在它们包含凭据的时候。要提高您的加密管理安全性应该:

使用 Windows 身份验证

保护您的连接字符串

用受限的 ACL 保护 UDL 文件

使用 Window 身份验证

当您使用 Windows 身份验证时,系统将为您管理凭据,而且凭据不会在网络上传递。您还应该避免将用户名和密码嵌入连接字符串中。

保护连接字符串

如果需要使用 SQL 身份验证,则连接中将包含用户名和密码。如果攻击者利用 Web 服务器上的源代码泄漏缺陷或者设法登录服务器,他们将可检索连接字符串。类似地,任何可合法登录服务器的人都可查看它们。因此应该使用加密保护连接字符串。

加密连接字符串

通过使用 DPAPI 加密连接字符串。通过 DPAPI 加密,可避免加密密钥管理问题,因为加密密钥是由平台管理的,而且绑定于一个特定的计算机或者一个 Windows 用户帐户上。要使用 DPAPI,您必须通过 P/Invoke 调用 Win32 DPAPI 函数。

有关如何构建托管包装类的详细信息,请参阅“如何:创建 DPAPI 库”,位于“Microsoft patterns & practices 第 I 卷,构建安全的 ASP.NET Web 应用程序:身份验证、授权和安全通讯”的“如何……”部分中,网址是:http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dnnetsec/html/secnetlpMSDN.asp

安全地存储加密的连接字符串

加密的连接字符串可以放在注册表中或者 Web.config 或者 Machine.config 文件中。如果您使用 HKEY_LOCAL_MACHINE 下的注册表项,可以对该项应用以下 ACL:

Administrators: Full Control
Process Account: Read

进程帐户是由数据访问程序集在其中运行的进程所决定的。这通常是一个 ASP.NET 进程或者一个企业服务服务器进程(如果您的解决方案使用企业服务中间层的话)。

此外您可以考虑使用 HKEY_CURRENT_USER,这可提供受限的访问。有关更多信息,请参阅“构建安全的程序集”单元中的“注册表”部分。

如果您使用 Microsoft Visual Studio_ .NET 数据库连接向导,连接字符串将以明文属性值的形式存储在 Web 应用程序代码隐藏文件或者 Web.config 文件中。这两种方式都应该避免。

虽然与使用受限的注册表项相比安全性可能更差,但您还是可能需要将加密的字符串存储在 Web.config 中以便更容易进行部署。在这种情况下,可以使用自定义的 <appSettings> 名称-值对,如下所示:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
 <appSettings>  
   <add key="connectionString" value="AQA..bIE=" />
 </appSettings>
 <system.web>
   ...
 </system.web>
</configuration>

要从 <appSettings> 元素访问加密文本,应该使用 ConfigurationSettings 类,如下所示:

using System.Configuration;
private static string GetConnectionString()
{
  return ConfigurationSettings.AppSettings["connectionString"];
}

不要将 Persist Security Info 设为‘True’或者‘Yes’

当您在连接字符串中包括 Persist Security Info 属性时,将使 ConnectionString 属性在返回给用户之前从连接字符串取得密码。默认设置 false(等效于忽略 Persist Security Info 属性)在连接数据库后会将此信息丢弃。

用受限的 ACL 保护 UDL 文件

如果您的应用程序在 ADO.NET OLE DB 托管数据提供程序中使用外部通用数据链接 (UDL) 文件,应该使用 NTFS 权限限制访问。使用以下受限的 ACL:

Administrators: Full Control
Process Account: Read

UDL 文件是没有加密的。更安全的方法是使用 DPAPI 加密连接字符串并将其存储在受限的注册表项中。

敏感数据

许多 Web 应用程序在数据库中存储一种形式或另一种形式的敏感数据。如果攻击者企图对您的数据库执行一个查询,对任何敏感的数据项(如信用卡号码)进行适当的加密是非常必要的。

如果您需要存储敏感的数据,则应该对其进行加密

保护跨网络传输的敏感数据

存储带 salt 值的密码散列值

如果您需要存储敏感的数据,则应该对其进行加密

如果可能,尽量避免存储敏感的数据。如果您必须存储敏感的数据,应该加密该数据。

使用 3DES 加密

为了在数据库中存储敏感的数据(如信用卡号码),应该使用一个坚固的对称加密算法,如 3DES。

在开发期间,启用 3DES 加密

1.

使用 RNGCryptoServiceProvider 类生成一个坚固的(192 位,24 字节)加密密钥。

2.

备份加密密钥,并将备份存储在物理上非常安全的位置。

3.

用 DPAPI 加密密钥并将其存储在一个注册表项中。使用以下 ACL 保护注册表项:

Administrators: Full Control
Process Account (for example ASPNET): Read

在运行时,在数据库中存储加密的数据

1.

获取要加密的数据。

2.

从注册表检索加密的密钥。

3.

使用 DPAPI 解密密钥。

4.

使用带有加密密钥的 TripleDESCryptoServiceProvider 类加密数据。

5.

在数据库中存储加密的数据。

在运行时,解密加密的密文

1.

从数据库检索加密的数据。

2.

从注册表检索加密的密钥。

3.

使用 DPAPI 解密密钥。

4.

使用 TripleDESCryptoServiceProvider 类解密数据。

通过此过程,如果用来加密密钥的 DPAPI 帐户被破坏了,将从备份位置检索 3DES 密钥的备份,并在新帐户下使用 DPAPI 加密。新的加密密钥可以存储在注册表中,而数据库中的数据仍然可以解密。

有关创建托管 DPAPI 库的更多信息,请参阅“如何:创建 DPAPI 库”,位于“Microsoft patterns & practices Volume I, Building Secure ASP.NET Web Applications:Authentication, Authorization, and Secure Communication”中,网址是:http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dnnetsec/html/secnetlpMSDN.asp

保护跨网络传输的敏感数据

通过网络发送到数据库服务器和来自数据库服务器的敏感数据中,可能包含特定于应用程序的数据或者数据库登录凭据。为了确保网络上数据的私密性和完整性,要么使用平台级的解决方案(如服务器之间使用 IPSec 加密通信信道的安全数据中心所提供的),要么配置应用程序建立 SSL 数据库连接。后一种方式需要在数据库服务器上安装服务器证书。

有关使用 SSL 和 IPSec 的更多信息,请参阅“Microsoft patterns & practices 第 I 卷,构建安全的 ASP.NET Web 应用程序:身份验证、授权和安全通讯”(网址是: )中“如何……”部分的“如何:在两个服务器之间使用 IPSec 提供安全通信”和“如何:使用 SSL 保护与 SQL Server 2000 的通信”。

存储带 salt 值的密码散列值

如果您需要实现包含用户名和密码的用户存储区,则不要以明文或者加密的格式存储密码。不要存储密码,而应该存储带附加 salt 的非可逆散列值以降低字典攻击的风险。

salt 值是一个强加密的随机数。

创建 salt

以下代码说明了如何通过使用 System.Security.Cryptography 命名空间中的 RNGCryptoServiceProvider 类提供的随机数生成功能生成 salt 值。

public static string CreateSalt(int size)
{
  RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider();
  byte[] buff = new byte[size];
  rng.GetBytes(buff);
  return Convert.ToBase64String(buff);
}

创建(带 salt 值的)散列值

以下代码片段说明了如何从提供的密码和 salt 值生成散列值。

public static string CreatePasswordHash(string pwd, string salt)
{
  string saltAndPwd = string.Concat(pwd, salt);
  string hashedPwd = 
        FormsAuthentication.HashPasswordForStoringInConfigFile(
                                             saltAndPwd, "SHA1");
  return hashedPwd;
}

更多信息

有关实现存储带 salt 值的密码散列值的用户存储区的更多信息,请参阅“如何:结合使用窗体身份验证与 SQL Server 2000”,位于“Microsoft patterns & practices 第 I 卷,构建安全的 ASP.NET Web 应用程序:身份验证、授权和安全通讯”(网址是:http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dnnetsec/html/secnetlpMSDN.asp)中的“如何……”部分。

异常管理

配置错误、代码中的错误或者恶意输入都会导致异常情况的出现。如果没有适当的异常管理,这些情况会暴露有关位置和数据源性质以及重要的连接详细信息等敏感信息。以下推荐实践适用于数据访问代码:

捕获和记录 ADO.NET 异常

确保数据库连接总是关闭的

ASP.NET 应用程序中使用一般性错误页

捕获和记录 ADO.NET 异常

将数据访问代码置于 try / catch 块中并处理异常。在编写 ADO.NET 数据访问代码时,ADO.NET 所生成的异常类型取决于数据提供程序。例如:

SQL Server .NET Framework 数据提供程序将生成 SqlExceptions

OLE DB .NET Framework 数据提供程序将生成 OleDbExceptions

ODBC .NET Framework 数据提供程序将生成 OdbcExceptions

捕获异常

以下代码使用 SQL Server .NET Framework 数据提供程序,并说明了如何捕获 SqlException 类型的异常。

try
{
  // Data access code
}
catch (SqlException sqlex) // more specific
{
}
catch (Exception ex) // less specific
{
}

日志记录异常

您还应该将来自 SqlException 类的详细信息记录下来。这个类公开了包含异常情况详细信息的属性。这包括一个说明错误的 Message 属性,一个唯一标识错误类型的 Number 属性,和一个包含其他信息的 State 属性。State 属性通常用来指示特定错误情况的某次出现。例如,如果存储过程在不止一行中出现了同样的错误,State 属性可指示特定的那一次。最后,Errors 集合包含可提供详细 SQL Server 错误信息的 SqlError 对象。

以下代码片段说明了如何通过使用 SQL Server .NET Framework 数据提供程序处理 SQL Server 错误情况:

using System.Data;
using System.Data.SqlClient;
using System.Diagnostics;
// Method exposed by a Data Access Layer (DAL) Component
public string GetProductName( int ProductID )
{
  SqlConnection conn = new SqlConnection(
        "server=(local);Integrated Security=SSPI;database=products");
  // Enclose all data access code within a try block
  try
  {
    conn.Open();
    SqlCommand cmd = new SqlCommand("LookupProductName", conn );
    cmd.CommandType = CommandType.StoredProcedure;
    cmd.Parameters.Add("@ProductID", ProductID );
    SqlParameter paramPN = 
         cmd.Parameters.Add("@ProductName", SqlDbType.VarChar, 40 );
    paramPN.Direction = ParameterDirection.Output;
    cmd.ExecuteNonQuery();
    // The finally code is executed before the method returns
    return paramPN.Value.ToString();  
  }
  catch (SqlException sqlex)
  {
    // Handle data access exception condition
    // Log specific exception details
    LogException(sqlex);
    // Wrap the current exception in a more relevant
    // outer exception and re-throw the new exception
    throw new Exception(
                  "Failed to retrieve product details for product ID: " + 
                   ProductID.ToString(), sqlex );
  }
  finally
  {
    conn.Close(); // Ensures connection is closed
  }
}
// Helper routine that logs SqlException details to the 
// Application event log
private void LogException( SqlException sqlex )
{
  EventLog el = new EventLog();
  el.Source = "CustomAppLog";
  string strMessage;
  strMessage = "Exception Number : " + sqlex.Number + 
               "(" + sqlex.Message + ") has occurred";
  el.WriteEntry( strMessage );
  foreach (SqlError sqle in sqlex.Errors)
  {
    strMessage = "Message: " + sqle.Message +
                 " Number: " + sqle.Number +
                 " Procedure: " + sqle.Procedure +
                 " Server: " + sqle.Server +
                 " Source: " + sqle.Source +
                 " State: " + sqle.State +
                 " Severity: " + sqle.Class +
                 " LineNumber: " + sqle.LineNumber;
    el.WriteEntry( strMessage );
  }
}

确保数据库连接总是关闭

如果出现了异常,关闭数据库连接并释放任何其他有限的资源是非常重要的。使用 finally 块,或者 C# using 语句确保无论是否出现异常情况都将关闭连接。以上代码已经说明了 finally 块的使用。您还可以使用 C# using 语句,如下所示:

using ((SqlConnection conn = new SqlConnection(connString)))
{
  conn.Open();
  // Connection will be closed if an exception is generated or if control flow
  // leaves the scope of the using statement normally
}

ASP.NET 应用程序中使用一般性错误页

如果您的数据访问代码是由一个 ASP.NET Web 应用程序或者 Web 服务调用的,应该配置 <customErrors> 元素以防止异常详细信息传回最终用户。您还可以通过使用这个元素指定一般性错误页,如下所示。

<customErrors mode="On" defaultRedirect="YourErrorPage.htm" />

为产品服务器设置 mode="On" 。当您在发布前开发和测试软件时,只能使用 mode="Off"。如果无法做到这一点,将导致返回给最终用户丰富的错误信息,如图 4 中所示。这些信息可能包括数据库服务器名称、数据库名称和连接凭据。


4. 详细的异常信息会暴露敏感的数据

图 4 还显示了在导致异常的行附近的数据访问代码中仍然存在的许多缺陷。具体如下:

连接字符串是硬编码的。

使用了高特权 sa 帐户来连接数据库。

sa 帐户的密码很脆弱。

SQL 命令构造很容易遭到 SQL 注入攻击,输入没有进行验证,而代码没有使用参数化的存储过程。

构建安全的数据访问组件

以下代码给出了 CheckProductStockLevel 方法的一个示例实现,该方法可以用来查询一个产品数据库的库存数量。这些代码说明了本单元中前面介绍的许多重要的数据访问代码安全功能。

using System;
using System.Data;
using System.Data.SqlClient;
using System.Text.RegularExpressions;
using System.Collections.Specialized;
using Microsoft.Win32;
using DataProtection;
public static int CheckProductStockLevel(string productCode)
{
  int quantity = 0;
  // (1) Code protected by try/catch block
  try
  {
    // (2) Input validated with regular expression
    //     Error messages should be retrieved from the resource assembly to help
    //     localization. The Localization code is omitted for the sake of brevity.
    if (Regex.IsMatch(productCode, "^[A-Za-z0-9]{12}$") == false)
      throw new ArgumentException("Invalid product code" );
    //(3) The using statement ensures that the connection is closed
    using (SqlConnection conn = new SqlConnection(GetConnectionString()))
    {
      // (4) Use of parameterized stored procedures is a countermeasure for
      //     SQL injection attacks
      SqlCommand cmd = new SqlCommand("spCheckProduct", conn);
      cmd.CommandType = CommandType.StoredProcedure;
      // Parameters are type checked
      SqlParameter parm = 
               cmd.Parameters.Add("@ProductCode", 
                                  SqlDbType.VarChar,12);
      parm.Value = productCode;
      // Define the output parameter
      SqlParameter retparm = cmd.Parameters.Add("@quantity", SqlDbType.Int);
      retparm.Direction = ParameterDirection.Output;
      conn.Open();
      cmd.ExecuteNonQuery();
      quantity = (int)retparm.Value;
    }
  }
  catch (SqlException sqlex)
  {
    // (5) Full exception details are logged. Generic (safe) error message
    //     is thrown back to the caller based on the SQL error code
    //     Log and error identification code has been omitted for clarity
    throw new Exception("Error Processing Request");
  }
  catch (Exception ex)
  {
    // Log full exception details
    throw new Exception("Error Processing Request");
  }
  return quantity;
}
// (6) Encrypted database connection string is held in the registry
private static string GetConnectionString()
{
  // Retrieve the cipher text from the registry; the process account must be
  // granted Read access by the key's ACL
  string encryptedString = (string)Registry.LocalMachine.OpenSubKey(
                                        @"Software/OrderProcessing/")
                                        .GetValue("ConnectionString");
  // Use the managed DPAPI helper library to decrypt the string
  DataProtector dp = new DataProtector(DataProtector.Store.USE_MACHINE_STORE);
  byte[] dataToDecrypt = Convert.FromBase64String(encryptedString);
  return Encoding.ASCII.GetString(dp.Decrypt(dataToDecrypt,null));
}

上面给出的代码说明了以下安全特征(用注释行的号码标识)。

1.

数据访问代码放在一个 try/catch 块中。这对于防止在出现异常时将系统级信息返回给调用方至关重要。调用方 ASP.NET Web 应用程序或者 Web 服务可能会处理异常并将合适的一般性错误消息返回给客户端,但是数据访问代码并不依赖于此。

2.

使用正则表达式验证输入。检查了所提供的产品 ID,以验证它只包含 A–Z 和 0–9 的字符,而且不超过 12 个字符。这种设计是用来防止 SQL 注入攻击的第一个对策。

3.

Microsoft Visual C#_ using 语句中创建了 SqlConnection 对象。这可确保无论是否发生异常,连接都会在方法中关闭。这将降低拒绝服务攻击的威胁,这种攻击试图使用所有可用的数据库连接。您可以通过使用 finally 块得到类似的功能。

4.

使用参数化的存储过程进行数据访问。这是另一个防止 SQL 注入攻击的对策。

5.

不将详细的错误信息返回给客户端。对异常详细信息进行记录,以辅助问题的诊断。

6.

加密的数据库连接字符串存储在注册表中。最安全的存储数据库连接字符串的方式之一,是使用 DPAPI 加密字符串和将加密的密文存储在一个受到保护的带有受限 ACL 的注册表项下。(例如,使用管理员:Full Control 和 ASP.NET 或者企业服务进程帐户:Read,这取决于哪个进程承载着组件。)

代码说明了如何从注册表中检索连接字符串,然后使用托管的 DPAPI 辅助库将其解密。这个库是在“如何创建 DPAPI 库”中提供的,该文章在“Microsoft patterns & practices 第 I 卷,构建安全的 ASP.NET Web 应用程序:身份验证、授权和安全通讯”的“如何……”部分中。

代码访问安全注意事项

所有数据访问都受代码访问安全权限要求的限制。选定的 ADO.NET 托管数据提供程序确定了准确的需求。以下表说明了必须为每个 ADO.NET 数据提供程序向数据访问程序集授予的权限。

表 1 ADO.NET 数据提供程序要求的代码访问安全权限
ADO.NET提供方必需的代码访问安全权限

SQL Server

SqlClientPermission
支持部分信任调用方,包括中度信任 Web 应用程序。

OLE DB

OleDbPermission*

Oracle

OraclePermission*

ODBC

OdbcPermission*

*在本单元写作时,.NET Framework 的 1.0和1.1 版上,OLE DB、Oracle 和 ODBC 提供程序只支持完全信任调用方。要从部分信任 Web 应用程序使用这些提供程序,您必须用沙箱保护数据访问的安全代码,这需要有专用的数据访问程序集。有关说明如何用沙箱保护数据访问的安全代码和从中度信任的 Web 应用程序使用 OLE DB 数据提供程序的例子,请参阅“在 ASP.NET 中使用代码访问安全”单元。

如果您使用 ADO.NET SQL Server 数据提供程序,必须通过代码访问安全策略授予代码 SqlClientPermission。完全和中度信任 Web 应用程序都具有这个权限。

是否代码被授予了 SqlClientPermission 将决定代码是否可连接 SQL Server。您还可以使用权限对数据库连接字符串的使用进行限制。例如,您可以强制应用程序使用集成安全,也可以确保如果使用 SQL Server 安全,就不接受空白密码。如果违反通过 SqlClientPermission 指定的规则,将引起运行时安全异常。

有关如何使用 SqlClientPermission 约束数据访问的更多信息,请参阅“代码访问安全实践”单元中的“数据访问”部分。

部署注意事项

经过安全设计和开发的数据访问组件如果没有以安全的方式进行部署,仍然会存在缺陷,遭到攻击。常见的部署实践是将数据访问代码和数据库置于不同服务器上。不同服务器经常是用内部防火墙分隔的,从而带来了更多部署方面的注意事项。对于开发人员和管理员,应该注意以下问题:

防火墙限制

连接字符串管理

登录帐户配置

登录审核

网络数据的私密性和完整性

防火墙限制

如果您通过防火墙连接 SQL Server,应该配置防火墙、客户端和服务器。通过使用 SQL Server 客户端网络实用工具配置客户端,通过使用服务器网络实用工具配置数据库服务器。默认时,SQL Server 将侦听 TCP 端口 1433,虽然这也可以更改。您必须在防火墙上打开选择的端口。

根据您选择的 SQL Server 的身份验证模式以及应用程序是否使用分布式事务,可能需要在防火墙上打开另外几个端口:

如果应用程序使用 Windows 身份验证连接 SQL Server,必须打开支持 Kerberos 或者 NTLM 身份验证的端口。

对于不使用 Active Directory 的网络,TCP 端口 139 通常对于 Windows 身份验证是必需的。有关端口需求的更多信息,请参阅 TechNet 文章“TCP and UDP Port Assignments,”,网址是:http://www.microsoft.com/technet/prodtechnol/windows2000serv/reskit/tcpip/part4/tcpappc.asp,和“Security Considerations for Administrative Authority,”,网址是:http://www.microsoft.com/technet/security/bestprac/bpent/sec2/seconaa.asp

如果您的应用程序使用分布式事务(例如自动化 COM+ 事务),可能还需要配置防火墙允许 DTC 流量,以在不同的 DTC 实例之间、DTC 和资源管理器(如 SQL Server)之间传递数据。

有关完整的配置详细信息,请参阅“保护数据库服务器”单元中的“端口”部分。

连接字符串管理

许多应用程序主要出于性能原因,将连接字符串存储在代码中。但是,性能上的优势是可以忽略的,而且使用文件系统缓存有助于确保将连接字符串存储在外部文件中,这可提供差不多的性能。使用外部文件存储连接字符串对于系统管理而言也是非常好的方式。

为了提高安全性,推荐的方式是使用 DPAPI 加密连接字符串。如果您的连接字符串包含用户名和密码,这将尤其重要。然后,决定在哪里存储加密的字符串。注册表是一个安全的位置,尤其在您使用 HKEY_CURRENT_USER 的时候,因为访问仅限于在相关用户帐户下运行的进程。一个更便于部署的替代方案是将加密的字符串存储在 Web.config 文件中。这两种方式在本单元中前面的“配置管理”部分进行了讨论。

登录帐户的配置

应用程序使用一个最低特权帐户连接数据库,这是非常重要的。这是降低 SQL 注入攻击威胁的主要技术之一。

作为一个开发人员,您必须与数据库管理员沟通应用程序登录需要访问的准确存储过程和(可能的)表。理想情况下,您应该只允许应用程序登录拥有与应用程序一起部署的一组受限存储过程的执行权限。

对 SQL 或者 Windows 帐户或者应用程序用来连接数据库的帐户使用坚固的密码。

请参阅本单元前面“授权”部分中对数据库中应用程序帐户的推荐授权策略。

登录审核

应该配置 SQL Server,记录失败的登录尝试,以及可能的成功登录尝试。审核失败登录尝试,对于检测尝试破解帐户密码的攻击者非常有用。

有关如何配置 SQL Server 审核的更多信息,请参阅“保护数据库服务器”单元。

网络数据的私密性和完整性

如果您使用 SQL 身份验证连接 SQL Server,应该确保登录凭据不会在网络上公开。或者在数据库服务器上安装证书(这将使 SQL Server 加密凭据),或者使用 IPSec 加密信道连接数据库。

推荐使用 IPSec 或者 SSL 连接数据库,以保护发送到数据库和来自数据库的敏感应用程序级数据。有关更多信息,请参阅“保护数据库服务器”单元。

小结

本单元说明了对数据访问代码的主要威胁,并重点论述了常见的缺陷。SQL 注入是应该留意的主要威胁之一。除非您使用本单元中讨论的正确对策,否则攻击者将可利用您的数据访问代码在数据库中运行任意命令。传统的安全措施(如防火墙和 SSL)无法提供对 SQL 注入攻击的防范。您应该彻底地验证输入并使用参数化的存储过程作为最低限度的防范措施。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值