ASP.NET 应用程序安全防范
主流媒体几乎每天都会报道又一个站点遭到了黑客攻击。 如果持续受到黑客高手群体入侵,开发人员会怀疑这些群体是否在使用高级技术执行他们的危害工作。 虽然某些现代攻击可能会十分复杂,但大部分有效攻击通常很简单并且多年来一直在使用。 幸运的是,此类攻击通常都能轻松防范。
我将用两篇文章的篇幅概述一些最常见的黑客攻击类型。 第一篇文章将介绍 SQL 注入和参数篡改,而在一月期发表的第二篇文章中,将重点介绍跨站点脚本和跨站点请求伪造。
如果您想知道是否只有大型站点才会担心黑客,答案非常简单: 所有开发人员都必须在其应用程序中考虑黑客企图。 您有责任保护自己的应用程序,并且您的用户期待这种保护。 由于存在大量自动黑客程序,即使 Internet 上的小型应用程序也会受到探测。 假定您的“用户”或“客户”表被盗,而这些表的密码还用于其他应用程序。 当然,建议用户始终使用不同的密码,不过事实上,用户没有这样做。 您不希望告诉用户您的应用程序成了信息被盗的通道。 我的一位朋友在小型博客中记述了他在珠穆朗玛峰的旅行, 他的博客受到黑客攻击,所有内容都被删除,但找不到任何明确的原因。 如果没有保护,基本上没有任何应用程序是安全的。
除非您的网络从物理上断开与外界通信设备的连接,否则,就可能有人通过以下方式侵入您的网络:代理配置问题;远程桌面协议 (RDP) 或虚拟专用网络 (VPN) 攻击;内部用户只要访问网页就会执行的远程代码执行漏洞;猜对密码;防火墙规则不足;Wi-Fi(攻击者在您的停车场里就可以破解大多数 Wi-Fi 安全保护);使人自愿透露敏感信息的社交工程骗局以及其他入口点。 除非完全与外部世界隔离,否则,没有任何环境可以认为是完全安全的。
既然我的危言耸听(我希望是)让您相信,黑客的威胁确实存在,并且您的所有应用程序都可能会被他人窥伺,那就让我们开始了解这些黑客行为以及如何防范这些行为!
SQL 注入
简介 SQL 注入是一种攻击行为,它会将一条或多条命令插入一个查询,从而构建一个并非出于开发人员本意的新查询。 当使用动态 SQL 时(即在代码中连接字符串以构建 SQL 语句),几乎总会出现这种情况。 如果要构建查询或过程调用,SQL 注入会出现在 Microsoft .NET Framework 代码中,还可能会出现在服务器端的 T-SQL 代码中,例如存储过程中的动态 SQL。
SQL 注入尤其危险,因为它不仅可以用于查询和编辑数据,而且还可以用于运行数据库命令,而这些命令仅受数据库用户或数据库服务帐户权限的限制。 如果您的 SQL Server 配置为以管理员帐户身份运行,而应用程序用户属于 sysadmin 角色,需要特别注意。 使用 SQL 注入的攻击可以运行系统命令来执行下列操作:
- 安装后门
- 通过端口 80 传输整个数据库
- 安装网络探查器来盗取密码和其他敏感数据
- 破解密码
- 枚举内部网络,包括扫描其他计算机端口
- 下载文件
- 运行程序
- 删除文件
- 加入僵尸网络
- 查询系统上存储的自动填写密码
- 创建新用户
- 创建、删除和编辑数据;创建和删除表
这个列表并不全面,危险仅仅受限于现有的权限和攻击者的创造性。
SQL 注入并不是什么新鲜事物,我常常问自己,它是否还是一个问题。 答案是肯定的,并且攻击者非常频繁地使用它。 事实上,除了拒绝服务 (DoS) 攻击,SQL 注入是最常用的攻击方法。
SQL 注入的运用方式 SQL 注入通常通过网页上的直接入口或参数篡改来运用,它通常不仅变更窗体或 URI。如果应用程序在不安全的 SQL 语句中使用 Cookie、标头等,则这些值也容易受到攻击(本文后面部分将进行讨论)。
让我们来看一个通过窗体篡改执行 SQL 注入的示例。 这是我在生产代码中多次看到的情形。 您的代码可能不完全相同,但这是开发人员检查登录凭据的常用方法。
下面是动态构建的用于检索用户登录的 SQL 语句:
-
- string loginSql = string.Format("select * from users where loginid= '{0}
-
- ' and password= '{1} '"", txtLoginId.Text, txtPassword.Text);
-
这将构建 SQL 语句:
-
- select * from dbo.users where loginid='Administrator' and
-
- password='12345'
-
这本身不是问题。 不过,假定窗体字段输入的外观如图 1 所示。
图 1:代替有效用户名的恶意输入
此输入将构建 SQL 语句:
-
- select * from dbo.users where loginid='anything' union select top 1 *
-
- from users --' and password='12345'
-
此示例将注入未知登录 ID“anything”,它本身不会返回任何记录。 不过,它会将这些结果与数据库中的第一条记录合并,而后续的“--”会注释掉查询的整个剩余部分,这样它将被忽略。 攻击者不仅可以登录,而且还可以在实际完全不了解有效用户名的情况下返回有效用户的代码调用记录。
显然,您的代码可能不会完全重现这种情形,但在分析您的应用程序时,需要重点考虑查询中包含的值通常来自的位置,这些位置包括:
- 窗体字段
- URL 参数
- 数据库中的存储值
- Cookie
- 标头
- 文件
- 独立存储
并非所有这些位置都很明显。 例如,为什么标头是潜在的问题呢? 如果您的应用程序在标头中存储用户配置文件信息并且在动态查询中使用这些值,将易于受到攻击。 如果使用动态 SQL,这些位置都可能成为攻击来源。
包含搜索功能的网页尤其容易成为攻击者的目标,因为此类网页提供直接尝试注入的方法。
这几乎相当于在易受攻击的应用程序中为攻击者提供了查询编辑器的功能。
近年来,人们已经非常注意安全问题了,因此在默认情况下,系统安全通常都得到了强化。 例如,在 SQL Server 2005 及更高版本(包括 SQL Express)的实例中禁用了系统过程 xp_cmdshell。 不过,希望这不会给您留下攻击者无法在您的服务器上运行命令的印象。 如果您的应用程序用于访问数据库的帐户权限级别足够高,攻击者仅需要注入以下命令就可以重新启用这一选项:
-
- EXECUTE SP_CONFIGURE 'xp_cmdshell', '1'
-
防范 SQL 注入的方法 首先讨论如何修复这一问题。 有一种很常用的修复传统 ASP 应用程序的方法,它只不过是替换短划线和引号而已。 遗憾的是,它仍广泛应用于 .NET 应用程序,通常作为唯一的保护手段:
-
- string safeSql = "select * from users where loginId = " + userInput.Replace("—-", "");
-
- safeSql = safeSql.Replace("'","''");
-
- safeSql = safeSql.Replace("%","");
-
此方法假定您已经:
- 使用这些类型的调用正确保护了每一条查询。 它需要开发人员即使在加班加点、夜以继日地进行编码后,也要记住在所有地方加入这些内嵌检查,而不是使用默认保护模式。
- 对每个参数进行了类型检查。 通常,开发人员迟早会出现纰漏,例如,开发人员忘记检查来自网页的参数实际是一个数字,然后在查询 ProductId 等内容时使用了该数字,但未针对它做任何字符串检查,毕竟,它是一个数字。 如果攻击者更改 ProductId,便可以从查询字符串进行读取,如下所示:
URI: http://yoursite/product.aspx?productId=10
结果
-
- select * from products where productid=10
-
然后,攻击者注入如下命令:
URI: http://yoursite/product.aspx?productId=10;select 1 col1 into #temp; drop table #temp;
其结果为
-
- select * from products where productid=10;select 1 col1 into #temp; drop table #temp;
-
哦不! 您刚注入到一个整数字段,该字段未通过字符串函数或类型检查过滤出来。 这称作直接注入攻击,因为它不需要引号,注入的部分直接在查询中使用而不必进行引用。 您可能会说:“我会始终确保检查我所有的数据”,但这样做需要开发人员手动检查每一个参数,很容易出错。 为什么不在整个应用程序中使用更好的模式从而以正确的方法修复问题?
那么,什么是防范 SQL 注入的正确方法呢? 对于大多数数据访问情形,这其实是非常简单的。 答案就是使用参数化调用。 只要将动态 SQL 调用进行参数化处理,就能实际获得安全的动态 SQL。 基本规则如下:
- 确保仅使用:
- 存储过程(无动态 SQL)
- 参数化查询(参见图 2)
图 2:参数化查询
-
- using (SqlConnection connection = new SqlConnection( ConfigurationManager.ConnectionStrings[1].ConnectionString))
-
- {
-
- using (SqlDataAdapter adapter = new SqlDataAdapter())
-
- {
-
- // Note we use a dynamic 'like' clause
-
- string query = @"Select Name, Description, Keywords From Product
-
- Where Name Like '%' + @ProductName + '%'
-
- Order By Name Asc";
-
- using (SqlCommand command = new SqlCommand(query, connection))
-
- {
-
- command.Parameters.Add(new SqlParameter("@ProductName", searchText));
-
- // Get data
-
- DataSet dataSet = new DataSet();
-
- adapter.SelectCommand = command;
-
- adapter.Fill(dataSet, "ProductResults");
-
- // Populate the datagrid
-
- productResults.DataSource = dataSet.Tables[0];
-
- productResults.DataBind();
-
- }
-
- }
-
- }
-
- 参数化存储过程调用(参见图 3)
图 3:参数化存储过程调用
-
- //Example Parameterized Stored Procedure Call
-
- string searchText = txtSearch.Text.Trim();
-
- using (SqlConnection connection = new SqlConnection(ConfigurationManager.ConnectionStrings[0].ConnectionString))
-
- {
-
- using (SqlDataAdapter adapter = new SqlDataAdapter())
-
- {
-
- // Note: you do NOT use a query like:
-
- // string query = "dbo.Proc_SearchProduct" + productName + ")";
-
- // Keep this parameterized and use CommandType.StoredProcedure!!
- string query = "dbo.Proc_SearchProduct";
-
- Trace.Write(string.Format("Query is: {0}", query));
-
- using (SqlCommand command = new SqlCommand(query, connection))
-
- {
-
- command.Parameters.Add(new SqlParameter("@ProductName", searchText));
-
- command.CommandType = CommandType.StoredProcedure;
-
- // Get the data.
- DataSet products = new DataSet();
-
- adapter.SelectCommand = command;
-
- adapter.Fill(products, "ProductResults");
-
- // Populate the datagrid.
- productResults.DataSource = products.Tables[0];
-
- productResults.DataBind();
-
- }
-
- }
-
- }
-
- 2. 存储过程中的动态 SQL 应为 sp_executesql 的参数化调用。 避免使用 exec(它不支持参数化调用)。 避免连接字符串与用户输入。 参见图 4。
图 4:sp_executesql 的参数化调用
-
- /*
- This is a demo of using dynamic sql, but using a safe parameterized query
- */
-
- DECLARE @name varchar(20)
-
- DECLARE @sql nvarchar(500)
-
- DECLARE @parameter nvarchar(500)
-
- /* Build the SQL string one time.*/
-
- SET @sql= N'SELECT * FROM Customer WHERE FirstName Like @Name Or LastName Like @Name +''%''';
-
- SET @parameter= N'@Name varchar(20)';
-
- /* Execute the string with the first parameter value.
- */
-
- SET @name = 'm%'; --ex.
- mary, m%, etc.
- note: -- does nothing as we would hope!
- EXECUTE sp_executesql @sql, @parameter,
-
- @Name = @name;
-
注意:由于缺少参数支持,请勿使用:exec 'select .. ' + @sql
- 3. 不要仅替换短划线和引号就认为安全了。 选择一致的数据访问方法(如前所述,这些方法可以防止 SQL 注入而无需开发者手动干预)并坚持使用。 如果您依赖转义例程而碰巧忘记在一个位置调用该例程,则可能会受到黑客攻击。 另外,转义例程的实现方法中可能存在漏洞,如 SQL 截断攻击的情况。
- 4. 通过类型检查和强制转换来验证输入(参见下面的“参数篡改”部分);使用正则表达式限定数据(例如,仅限字母数字数据)或者从已知来源提取重要数据;不信任来自网页的数据。
- 5. 审计数据库对象权限以限制应用程序用户范围,从而对攻击面进行限制。 仅当用户必须执行某些操作(例如更新、删除和插入)时,才授予相应的权限。 每个单独的应用程序都应该具有自己的数据库登录并具有有限的权限。 使用我的开源 SQL Server Permissions Auditor 可以帮助完成此任务;请访问sqlpermissionsaudit.codeplex.com 阅读相关内容。
如果使用参数化查询,审计您的表权限就显得很重要。 参数化查询需要用户或角色具有表访问权限。 您的应用程序可能已针对 SQL 注入进行了保护,但当其他未受保护的应用程序访问您的数据库时怎么办? 攻击者可以从您的数据库中开始查询,因此,您将需要确保每个应用程序都有自己唯一的受限登录。 您还应该审计数据库对象(例如视图、过程和表)的权限。 通常,只要存储过程中没有动态 SQL,存储过程就仅要求过程本身的权限,而不要求表的权限,因此比较容易管理它的安全。 现在,我的 SQL Server Permissions Auditor 又可以一展身手了。
请注意,实体框架在后台使用参数化查询,因此,对于正常使用情形,是可以防范 SQL 注入的。 一些人喜欢将他们的实体映射到存储过程,而不是为动态参数化查询开放表权限,不过,这两种情况都有有效参数,您可以自行决定采用哪一种。 请注意,如果显式使用实体 SQL,您需要了解有关查询的其他一些安全注意事项。 请参阅 MSDN 库页面中的“安全注意事项(实体框架)”(网址为:msdn.microsoft.com/library/cc716760)。
参数篡改
简介 参数篡改是一种攻击行为,通过这种行为可以变更参数以达到更改应用程序预期功能的目的。 参数可以位于窗体、查询字符串、Cookie、数据库等对象上。 我将讨论涉及基于 Web 的参数的攻击。
参数篡改的使用方式 攻击者变更参数以欺骗应用程序执行偏离预期的操作。 假定您通过从查询字符串读取用户 ID 来保存其记录。 这样做是否安全? 否。 攻击者可以篡改应用程序中的 URL,这类似于图 5 所示的情形。
图 5:变更的 URL
这样,攻击者可以加载非预期的用户帐户。 如下所示的应用程序代码时常会错误地信任此 userId:
-
- // Bad practice!
- string userId = Request.QueryString["userId"];
-
- // Load user based on this ID
-
- var user = LoadUser(userId);
-
有更好的办法吗? 有! 您可以从更可靠的来源(例如用户会话)或从成员资格或配置文件提供程序读取值,而不是信任窗体。
还有各种各样的工具可用于轻松执行篡改操作,被篡改的也不仅限于查询字符串。 建议您掌握一些可用于查看页面上的隐藏元素的 Web 浏览器开发人员工具栏。 我相信,您将对所发现的内容以及篡改数据如此简单感到吃惊。 我们来看一下图 6 中显示的“Edit User”页。 如果您在页面上发现隐藏字段,就可以看到窗体中嵌入的用户 ID,它正在准备进行篡改(参见图 7)。 此字段用作该用户记录的主键,篡改它可以变更保存回数据库的记录。
图 6:“Edit User”窗体
图 7:在窗体上发现隐藏字段
防范参数篡改的方法 不要信任用户提供的数据,并且对作为决策依据的收到数据进行验证。 通常,您不会注意用户是否变更其存储在配置文件内的中间名。 不过,您无疑关心他是否篡改了表示其用户记录关键字的隐藏窗体 ID。 在此类情况下,您可以从服务器上的已知来源(而非网页)提取可靠数据。 此信息可以在登录时存储在用户会话中或存储在成员资格提供程序中。
例如,相对于使用窗体数据,使用来自成员资格提供程序信息的方法要好得多:
-
- // Better practice
-
- int userId = Membership.GetUser().ProviderUserKey.ToString();
-
- // Load user based on this ID
-
- var user = LoadUser(userId);
-
既然您已经了解了来自浏览器的不可靠数据是什么样子,就让我们来看一些验证此类数据以进行一定清理工作的示例。 下面列出了一些典型的 Web 窗体方案:
-
- // 1.
- No check!
- Especially a problem because this productId is really numeric.
- string productId = Request.QueryString["ProductId"];
-
- // 2.
- Better check
-
- int productId = int.Parse(Request.QueryString["ProductId"]);
-
- // 3.Even better check
-
- int productId = int.Parse(Request.QueryString["ProductId"]);
-
- if (!IsValidProductId(productId))
-
- {
-
- throw new InvalidProductIdException(productId);
-
- }
-
图 8 显示了一个典型的 MVC 方案,其中的模型绑定执行基本的自动类型转换,而不必显式转换参数。
图 8:使用 MVC 模型绑定
-
- [HttpPost]
-
- [ValidateAntiForgeryToken]
-
- public ActionResult Edit([Bind(Exclude="UserId")] Order order)
-
- {
-
- ...
- // All properties on the order object have been automatically populated and
-
- // typecast by the MVC model binder from the form to the model.
- Trace.Write(order.AddressId);
-
- Trace.Write(order.TotalAmount);
-
- // We don’t want to trust the customer ID from a page
-
- // in case it’s tampered with.
- // Get it from the profile provider or membership object
-
- order.UserId = Profile.UserId;
-
- // Or grab it from this location
-
- order.UserId = Membership.GetUser().ProviderUserKey.ToString();
-
- ...
- order.Save();}
-
- ...
- // etc.
- }
-
模型绑定是一项出色的模型-视图-控制器 (MVC) 功能,它可以帮助执行参数检查,这是因为 Order 对象上的属性将自动填充并转换为根据窗体信息定义的类型。 您可以在模型上定义数据批注,也可以包含多种不同的验证。 仅需注意限制允许填充什么属性,但仍不要信任重要项的页面数据。 使每个视图都有一个 ViewModel,这是一条不错的经验,这样您可以在此编辑示例的模型中完全排除 UserId。
请注意,我在此处使用 [Bind(Exclude)] 属性限制 MVC 绑定到模型中的内容以便控制我信任或不信任的内容。 这可以确保 UserId 不会来自窗体数据,从而不会被篡改。 模型绑定和数据批注已超出本文的讨论范围。 我在这里仅是一带而过,用来展示参数键入如何在 Web 窗体和 MVC 中使用。
如果必须包含“信任”网页上的一个 ID 字段,请访问 MVC 安全扩展链接 (mvcsecurity.codeplex.com),以了解协助完成此任务的属性。
结束语
在本文中,我介绍了两种最常见的对应用程序进行黑客攻击的方法。 如您所见,仅需在您的应用程序中进行少量更改,就可以防止或者至少限制此类攻击。 当然,这些攻击会有变体,并且可能会通过其他方式利用您的应用程序。 我将在下一期中再介绍两种类型的攻击:跨站点脚本和跨站点请求伪造。