SQL 注入攻击及一些防范技巧






4.90/5 (226投票s)
讨论了SQL注入攻击的各个方面、代码中应注意的事项以及如何防范SQL注入攻击。
引言
软件应用程序的安全性是一个日益重要的话题。在本文中,我将讨论SQL注入攻击的各个方面、代码中应注意的事项以及如何防范SQL注入攻击。尽管这里使用的技术是SQL Server 2000和.NET Framework,但提出的普遍思想适用于任何现代数据驱动的应用程序框架,这使得攻击可能对任何依赖该框架的应用程序类型都存在潜在可能。
什么是SQL注入攻击?
SQL注入攻击是一种攻击形式,它源于未经验证的、无效的用户输入。其目的是欺骗数据库系统运行恶意代码,从而泄露敏感信息或以其他方式破坏服务器。
攻击主要有两种类型。第一类攻击是指攻击者立即收到所需结果,这可能是通过他们交互的应用程序的直接响应,也可能是通过电子邮件等其他响应机制。第二类攻击是指攻击者注入一些数据,这些数据将驻留在数据库中,但攻击负载不会立即激活。我将在本文后面详细讨论这两种攻击。
攻击者可能做什么的例子
在下面的例子中,假设正在使用一个网站来攻击数据库。如果您想到一个典型的SQL语句,您可能会想到类似这样的内容:
SELECT ProductName, QuantityPerUnit, UnitPrice
FROM Products
WHERE ProductName LIKE 'G%'
攻击者的目标是将他们自己的SQL注入到应用程序将用于查询数据库的语句中。例如,如果上面的查询是由网站上的搜索功能生成的,那么用户可能输入了“G”作为他们的查询。如果服务器端代码直接将用户输入插入到SQL语句中,它可能看起来像这样:
string sql = "SELECT ProductName, QuantityPerUnit, UnitPrice "+
"FROM Products " +
"WHERE ProductName LIKE '"+this.search.Text+"%';
SqlDataAdapter da = new SqlDataAdapter(sql, DbCommand);
da.Fill(productDataSet);
如果数据有效,这一切都很好,但如果用户输入了意外的内容呢?如果用户输入了怎么办?
' UNION SELECT name, type, id FROM sysobjects;--
注意开头的单引号;它关闭了原始SQL语句中的开头引号。另外,请注意末尾的两个连字符;这开始了一个注释,这意味着原始SQL语句中剩余的任何内容都会被忽略。
现在,当攻击者查看本应列出用户搜索过的产品页时,他们会得到数据库中所有对象名称及其类型的列表。从这个列表中,攻击者可以看到有一个名为Users的表。如果他们记下了Users表的id
,他们就可以注入以下内容:
' UNION SELECT name, '', length FROM syscolumns
WHERE id = 1845581613;--
这将为他们提供Users表中列名的列表。现在他们有足够的信息来访问用户列表、密码,以及他们是否拥有网站的管理员权限。
' UNION SELECT UserName, Password, IsAdmin FROM Users;--
假设有一个名为Users的表,其中包含名为UserName
和Password
的列,可以将它与原始查询联合,结果将被解释为好像UserName
是产品名称,而Password
是每单位数量。最后,由于攻击者发现有一个IsAdmin
列,他们可能会检索其中的信息。
锁定
安全是需要在多个层面解决的问题,因为链条的强度取决于其最薄弱的环节。当用户与软件交互时,链条中有许多环节;如果用户是恶意的,他可能会试图攻击这些环节,找到薄弱点并试图在该点上破坏系统。考虑到这一点,开发人员不能因为实施了一个安全措施,或只在系统的一个部分设置了一系列安全措施就沾沾自喜。
一个使用Windows身份验证(它根据用户登录的身份获取用户现有的凭据)并且位于公司网络内部、对Internet用户不可用的内网网站,可能会给人一种只有授权用户才能访问内网Web应用程序的印象。然而,如果安全性没有超越该级别,已验证的用户就有可能获得未经授权的访问。一些统计数据支持这样的观点,即大多数安全漏洞是内部人员所为,而不是外部人员攻击系统。
考虑到这一点,即使应用程序只允许通过经过仔细验证和清理的有效数据,也要采取其他安全措施。这在应用程序层之间尤其重要,因为可能存在更高的请求或结果欺骗机会。
例如,如果一个Web应用程序要求用户选择一个日期,那么通常会在Web页面上的某个JavaScript函数中检查日期的值,然后才会将任何数据发布回服务器。这通过减少大量服务器请求之间的等待时间来改善用户体验。然而,服务器端仍需要再次验证该值,因为它可以通过故意构造的无效日期来欺骗请求。
加密数据
从攻击者设法突破所有其他防御的假设出发,哪些信息是如此敏感以至于需要保密?加密的候选包括用户登录详细信息或财务信息,如信用卡详细信息。
对于密码之类的项目,用户密码可以存储为“加盐哈希”。当用户创建密码时,应用程序会创建一个随机生成的“盐”值,并将其附加到密码中,然后将密码和盐值通过单向加密例程进行处理,例如.NET Framework的帮助类FormsAuthentication
(HashPasswordForStoringInConfigFile 方法)。结果是一个加盐哈希,它与明文盐字符串一起存储在数据库中。
加盐哈希的价值在于字典攻击将无法奏效,因为每个字典都必须重建,附加各种盐值并重新计算每个项目的哈希值。虽然仍然有可能通过暴力破解来确定密码,但使用盐(即使已知)大大减慢了过程。盐的第二个优点是它掩盖了两个独立用户恰好使用相同密码的情况,因为如果给定不同的盐值,每个用户的加盐哈希值都会不同。
最小权限 - 数据库账户
运行一个使用数据库管理员帐户连接到数据库的应用程序,可能会导致攻击者执行几乎无限的数据库命令。管理员能做的一切,攻击者也能做。
使用上面的示例应用程序,攻击者可以注入以下内容来发现服务器上硬盘的内容。
第一个命令用于在数据库中创建一个临时存储并用一些数据填充它。以下注入的代码将创建一个具有与将要调用的扩展存储过程结果集相同结构的表。然后,它用扩展存储过程的结果填充该表。
'; CREATE TABLE haxor(name varchar(255), mb_free int);
INSERT INTO haxor EXEC master..xp_fixeddrives;--
必须进行第二次注入攻击才能再次提取数据。
' UNION SELECT name, cast((mb_free) as varchar(10)), 1.0 FROM haxor;--
这会返回磁盘名称以及可用容量(以兆字节为单位)。现在知道了磁盘的驱动器号,就可以进行新的注入攻击以找出这些磁盘上有哪些内容。
'; DROP TABLE haxor;CREATE TABLE haxor(line varchar(255) null);
INSERT INTO haxor EXEC master..xp_cmdshell 'dir /s c:\';--
同样,第二次注入攻击用于再次提取数据。
' UNION SELECT line, '', 1.0 FROM haxor;--
默认情况下,xp_cmdshell
只能由具有sysadmin
特权的用户(如sa
)执行,而CREATE TABLE
只能由sysadmin
、db_dbowner
或db_dlladmin
用户使用。因此,重要的是以执行应用程序必要功能所需的最低权限来运行应用程序。
最小权限 - 进程账户
当SQL Server实例安装在计算机上时,它会创建一个在后台运行的服务,并处理连接到它的应用程序的命令。默认情况下,此服务安装为使用本地系统帐户。这是Windows机器上最强大的帐户,它甚至比管理员帐户更强大。
如果攻击者有机会突破SQL Server本身的限制,例如通过扩展存储过程xp_cmdshell
,那么他们就可以获得对SQL Server所在机器的无限制访问。
Microsoft建议在安装SQL Server时,为服务指定一个域帐户,并将其权限设置为仅限于必要的资源。这样,攻击者将受到运行SQL Server所需权限集的限制。
清理和验证输入
在许多应用程序中,开发人员通过对用户输入的字符串执行替换操作,绕过了使用单引号访问系统的可能性。这对于有效的理由很有用,例如能够输入姓氏,如“O'Brian”或“D'Arcy”,因此开发人员可能甚至没有意识到他们已经部分挫败了SQL注入攻击。例如:
string surname = this.surnameTb.Text.Replace("'", "''");
string sql = "Update Users SET Surname='"+surname+"' "+
"WHERE id="+userID;
在上述所有注入攻击示例中,如果出现类似这样的情况,攻击都将停止。
然而,许多应用程序需要用户输入数字,而这些数字不需要像文本字符串那样转义单引号。如果一个应用程序允许用户按年份查看他们的订单,应用程序可能会执行类似这样的SQL:
SELECT * FROM Orders WHERE DATEPART(YEAR, OrderDate) = 1996
为了让应用程序执行它,用于构建SQL命令的C#代码可能看起来像这样:
string sql = "SELECT * FROM Orders WHERE DATEPART(YEAR, OrderDate) = "+
this.orderYearTb.Text);
再次轻松注入代码到数据库中。这次攻击者所需要做的就是以数字开始他们的攻击,然后注入他们想要运行的代码。就像这样:
0; DELETE FROM Orders WHERE ID = 'competitor';--
因此,必须检查用户输入以确定它确实是一个数字,并且在有效范围内。例如:
string stringValue = orderYearTb.Text;
Regex re = new Regex(@"\D");
Match m = re.Match(someTextBox.Text);
if (m.Success)
{
// This is NOT a number, do error processing.
}
else
{
int intValue = int.Parse(stringValue);
if ((intValue < 1990) || (intValue > DateTime.Now.Year))
{
// This is out of range, do error processing.
}
}
二次注入攻击
二次注入攻击是指数据在数据库中潜伏,直到某个未来事件发生。这通常发生在数据一旦进入数据库,就被认为是干净的,不再被检查。然而,数据经常用于查询中,而这些查询仍然可能造成损害。
考虑一个允许用户设置一些喜欢的搜索条件的应用程序。当用户定义搜索参数时,应用程序会转义所有单引号,这样在将喜欢的数据插入数据库时就不会发生一次注入攻击。然而,当用户进行搜索时,数据会从数据库中检索出来,并用于形成第二个查询,然后执行实际的搜索。正是这个第二个查询成为了攻击的目标。
例如,如果用户输入了以下内容作为搜索条件:
'; DELETE Orders;--
应用程序接受此输入并转义单引号,以便最终的SQL语句可能看起来像这样:
INSERT Favourites (UserID, FriendlyName, Criteria)
VALUES(123, 'My Attack', ''';DELETE Orders;--')
它会毫无问题地进入数据库。然而,当用户选择他们喜欢的搜索时,数据会被检索到应用程序,应用程序会形成一个新的SQL命令并执行它。例如,C#代码可能看起来像:
// Get the valid user name and friendly name of the favourite
int uid = this.GetUserID();
string friendlyName = this.GetFriendlyName();
// Create the SQL statement to retrieve the search criteria
string sql = string.Format("SELECT Criteria FROM Favourites "+
"WHERE UserID={0} AND FriendlyName='{1}'",
uid, friendlyName);
SqlCommand cmd = new SqlCommand(sql, this.Connection);
string criteria = cmd.ExecuteScalar();
// Do the search
sql = string.Format("SELECT * FROM Products WHERE ProductName = '{0}'",
criteria);
SqlDataAdapter da = new SqlDataAdapter(sql, this.Connection);
da.Fill(this.productDataSet);
第二个数据库查询,当完全展开后,看起来像这样:
SELECT * FROM Products WHERE ProductName = ''; DELETE Orders;--
它不会返回预期的查询结果,但该公司却失去了所有的订单。
参数化查询
SQL Server,像许多数据库系统一样,支持参数化查询的概念。这是指SQL命令使用参数而不是将值直接注入命令中。如果使用了参数化查询,上述特定的二次注入攻击将不会发生。
应用程序开发人员可能会像这样构造一个SqlCommand
对象:
string cmdText=string.Format("SELECT * FROM Customers "+
"WHERE Country='{0}'", countryName);
SqlCommand cmd = new SqlCommand(cmdText, conn);
参数化查询会像这样:
string commandText = "SELECT * FROM Customers "+
"WHERE Country=@CountryName";
SqlCommand cmd = new SqlCommand(commandText, conn);
cmd.Parameters.Add("@CountryName",countryName);
值被一个占位符(参数)替换,然后参数的值被添加到命令的Parameters
集合中。
虽然许多二次注入攻击可以通过使用参数来预防,但它们只能用于SQL语句中允许使用参数的地方。应用程序可能会根据用户偏好返回可变大小的结果集。SQL语句将包括TOP
关键字以限制结果集,然而,在SQL Server 2000中,TOP
只能接受字面值,因此应用程序必须将该值注入SQL命令中才能获得该功能。例如:
string sql = string.Format("SELECT TOP {0} * FROM Products", numResults);
使用存储过程
存储过程为软件系统的设计增加了一个额外的抽象层。这意味着,只要存储过程的接口保持不变,底层表结构就可以更改,而对使用数据库的应用程序没有明显影响。这种抽象层还为潜在攻击者设置了一个额外的障碍。如果对SQL Server中的数据只允许通过存储过程进行访问,那么就不需要显式地在任何表上设置权限。因此,任何表都不应该直接暴露给外部应用程序。为了让外部应用程序读取或修改数据库,它必须通过存储过程。尽管某些存储过程如果使用不当可能会损坏数据库,但任何能够减少攻击面的东西都是有益的。
存储过程可以编写来验证发送给它们的所有输入,以确保数据完整性超越了表中其他可用的简单约束。可以检查参数的有效范围。信息可以与其他表中的数据进行交叉检查。
例如,考虑一个包含网站用户详细信息的数据库,其中包括用户名和密码。攻击者无法获取密码列表,甚至一个密码,这是很重要的。存储过程的设计方式是,可以传入密码,但绝不会将密码放入任何结果集中。用于注册和验证网站用户的存储过程可能是:
RegisterUser
VerifyCredentials
ChangePassword
RegisterUser
将用户名和密码作为参数(可能还包括注册网站所需的其他信息)并返回UserID
。
VerifyCredentials
将用于通过接受用户名和密码来登录网站。如果匹配,则返回UserID
;否则返回NULL
值。
ChangePassword
将接收UserID
、旧密码和新密码。如果UserID
和密码匹配,则可以更改密码。将返回一个指示成功或失败的值。
上面的例子表明,密码始终包含在数据库中,并且从不暴露。
存储过程的注意事项
虽然存储过程似乎是防止注入攻击的绝佳灵丹妙药,但事实并非如此。如上所述,验证数据以检查其是否正确很重要,而存储过程能够做到这一点是一个明确的好处;但是,如果存储过程将使用EXEC(some_string)
(其中some_string
是从数据和字符串字面量构建而成的新命令),那么验证数据就更加重要了。
例如,如果存储过程要修改数据库的数据模型,例如创建表,代码可能会这样写:
CREATE PROCEDURE dbo.CreateUserTable
@userName sysname
AS
EXEC('CREATE TABLE '+@userName+
' (column1 varchar(100), column2 varchar(100))');
GO
很明显,无论@userName
包含什么,都会被附加到CREATE
语句中。攻击者可以注入应用程序中的代码,将用户名设置为:
a(c1 int); SHUTDOWN WITH NOWAIT;--
这将立即停止SQL Server,而不会等待其他请求完成。
验证输入以确保不存在非法字符非常重要。可以将应用程序设置为确保用户名不允许包含空格,并且可以在构建CREATE
语句之前就拒绝它。
如果存储过程将根据现有对象(如表或视图)构建SQL命令,那么它应该检查该对象是否存在。例如:
CREATE PROCEDURE dbo.AlterUserTable
@userName sysname
AS
IF EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'dbo'
AND TABLE_TYPE = 'BASE TABLE'
AND TABLE_NAME = @userName)
BEGIN
// The table is known to exist
// construct the appropriate command here
END
GO
错误消息
错误消息对攻击者很有用,因为它们提供了关于数据库的额外信息,这些信息可能无法获得。人们通常认为应用程序返回错误消息给用户是有帮助的,这样如果问题持续存在,他们就有一些有用的信息可以告诉技术支持团队。应用程序通常会包含类似这样的代码:
try
{
// Attempt some database operation
}
catch(Exception e)
{
errorLabel.Text = string.Concat("Sorry, your request failed. ",
"If the problem persists please report the following message ",
"to technical support", Environment.Newline, e.Message);
}
一个更好的解决方案,既不损害安全性,就是显示一个通用的错误消息,简单地说明发生了错误,并附带一个唯一的ID。该唯一ID对用户没有意义,但它会与服务器上的实际错误诊断一起记录下来,技术支持团队可以访问这些诊断。上面的代码将改为:
try
{
// Attempt some database operation
}
catch(Exception e)
{
int id = ErrorLogger.LogException(e);
errorLabel.Text = string.Format("Sorry, your request Failed. "+
"If the problem persists please report error code {0} "
"to the technical support team.", id);
}
摘要
- 加密敏感数据。
- 使用具有必要最小权限的帐户访问数据库。
- 使用具有必要最小权限的帐户安装数据库。
- 确保数据有效。
- 进行代码审查,检查二次注入攻击的可能性。
- 使用参数化查询。
- 使用存储过程。
- 在存储过程中重新验证数据。
- 确保错误消息不会透露应用程序或数据库的内部架构。
参考文献
- SQL Injection Attacks by Example展示了一个人在客户的内网网站上第一次尝试安全审查时的思考过程。
- 如何使用Forms Authentication和SQL Server 2000演示了创建一种身份验证机制,该机制将密码存储为加盐哈希值。
最后
©2005 Colin Angus Mackay。保留所有权利。
本文的唯一授权版本可在以下网址获取:www.codeproject.com,www.ScottishDevelopers.com和我的博客。