在本文中我将讨论一些新观点,其结果可能会修改 SQL 语句或注入 SQL 代码,即使代码对分隔字符进行了转义。我首先介绍一些构建分隔标识符和 SQL 字符串的最佳实践,然后我将介绍攻击者注入 SQL 代码的几种新方法,以帮助您保护您的应用程序。
分隔标识符和字符串
在 SQL Server™ 中,有两种字符串变量:唯一可识别 SQL 对象(如表、视图和存储过程)的 SQL 标识符,以及用于表示数据的字符串。分隔 SQL 标识符的方法与分隔数据字符串的方法不同。我们将讨论需要使用这些数据变量的动态 SQL 构建方法的最佳实践。
如果 SQL 对象名使用了关键字,或者对象名中包含了特殊字符,则您需要使用分隔标识符。假如您需要删除 my_dbreader 登录名,则可以执行以下语句:
DROP LOGIN my_dbreader
如果您试着删除一个使用 DROP 作为其名称(也是关键字)的登录名会怎样?如果您使用以下 SQL 语句,SQL Server 会返回一个语法错误。
DROP LOGIN DROP如果您要删除像 my][dbreader 这样的登录名又会怎样?这也会引发语法错误。
在上面两个例子中,由于登录名为关键字或包含特殊字符,因此您需要提供一些开始和结束标记,以便 SQL Server 可以识别 SQL 语句中的对象名。
您可以使用双引号或方括号作为 SQL 标识符的分隔符,而在 QUOTED_IDENTIFIER 设置(一种基于连接的设置)启用时您可以只使用双引号。为简便起见,最好始终使用方括号。
要成功删除 DROP 登录名,您可以使用方括号来构造您的 SQL 语句:
DROP LOGIN [DROP]但以下语句会怎样?
DROP LOGIN [my][dbreader]在这种特殊情况下,由于登录名 my][dbreader 中包含分隔字符,因此 SQL 会认为 [my] 是登录名,因为它被包含在方括号内。由于 [dbreader] 跟在登录名后面,因此该语句并不构成正确的 SQL 语句,会导致语法错误。您可以通过用另一个右方括号对上面的右方括号进行转义来解决这一问题。因此,如果您执行以下语句,SQL Server 将成功删除 my][dbreader 登录名:
DROP LOGIN [my]][dbreader]转义机制只是使右方括号的出现次数增加了一倍。您无需改动其他字符,包括左方括号。
准备分隔字符串与准备分隔 SQL 标识符类似,主要区别就是需要使用的分隔字符。在介绍与构建分隔字符串相似的规则之前,先来看以下几个例子。
假设您希望创建 dbreader 登录名,密码是 P@$$w0rd。您会使用以下 SQL 语句:
CREATE LOGIN [dbreader] WITH PASSWORD = 'P@$$w0rd'在该语句中,P@$$w0rd 是由单引号分隔的字符串数据,因此 SQL 知道该字符串从哪里开始,到哪里结束。但如果字符串数据中包含单引号会怎样?SQL Server 会引发一个错误,因为该语句为无效语句:
CREATE LOGIN [dbreader] WITH PASSWORD = 'P@$$'w0rd'您需要对字符串中出现的所有单引号进行转义,构造有效的 SQL 语句:
CREATE LOGIN [dbreader] WITH PASSWORD = 'P@$$''w0rd'当您执行该语句时,SQL Server 将创建 dbreader 登录名,密码为 P@$$'w0rd。
您还可以使用双引号作为分隔符,但正如我前面提到的,这种方法是否成功完全取决于 QUOTED_IDENTIFIER 设置是否已开启。因此,最好始终使用单引号作为字符串的分隔符。
T-SQL 函数
可以看出,处理标识符和字符串的规则相对比较简单,如果您预先知道该字符串,可以手动对其进行分隔。但如果您要根据用户输入构建动态的 T-SQL 语句,该怎么办?您需要通过自动的方法来完成。两种 T-SQL 函数可帮您准备分隔字符串,它们是 QUOTENAME 和 REPLACE。
QUOTENAME 会返回一个 Unicode 字符串,并添加了分隔符,以使该输入字符串成为有效标识符。QUOTENAME 函数使用以下语法:
QUOTENAME ( 'string' [ , 'delimiter' ] )您可以将要分隔的字符串和一个用作分隔符的单字符字符串传给 QUOTENAME。分隔符可以是方括号、单引号或双引号。
此函数主要用于准备分隔 SQL 标识符,因此它只接受 sysname 类型,在 SQL Server 中为 nvarchar(128)。您还可以使用此函数准备分隔 SQL 字符串,但由于参数长度的限制,因此它支持的字符串长度最多为 128 个字符(在这一点上,REPLACE 函数可发挥其用途)。图 1 显示了 sp_addlogin 如何使用 QUOTENAME 来准备分隔登录名和密码字符串。可以看出,由于 @loginname 和 @passwd 均为 sysname 类型,因此可使用 QUOTENAME 函数准备分隔 SQL 标识符和分隔字符串。因此,即使有人传递的是 @loginname = 'my[]dbreader' 和 @passwd = 'P@$$''w0rd',也不会有任何 SQL 注入机会,因为 QUOTENAME 对分隔字符进行了适当转义:
create login [my[]]dbreader] with password = 'P@$$''w0rd'
REPLACE 函数会将某个给定字符串的所有出现之处全部替换为指定的替代字符串。与 QUOTENAME 不同,REPLACE 函数对其接受的参数没有长度限制:
REPLACE ( 'string1' , 'string2' , 'string3' )
REPLACE 带有三个字符串:string1 是要编辑的表达式,string2 是 string1 中要被替换的项,string3 是用于取代 string2 的项。任何字符串表达式都可由字符或二进制数据组成。
要准备分隔 SQL 字符串,您可以使用 REPLACE 使单引号的出现次数增加一倍,但您需要手动添加分隔符(开始和结束的单引号)。图 2 显示了 sp_attach_single_file_db 如何使用此函数准备一个文件的已转义的物理名称。由于 @physname 是 nvarchar(260),因此您无法使用 QUOTENAME 准备分隔字符串,这就是为何要使用 REPLACE 的原因。因此,即使有人传递带单引号的字符串,他们也无法打破 SQL 语句,注入任何 SQL 代码。
SQL 注入漏洞
接下来介绍存储过程,它可在验证了当前密码后更改用户帐户的密码(参见图 3)。
快速浏览存储过程,会发现没有任何参数对单引号进行转义,这同样容易受到 SQL 注入攻击。攻击者可以传递几个特定的参数,并将 SQL 语句修改为:
update Users set password='NewP@ssw0rd' where username='admin' --' and password='dummy'结果是,无需实际密码即可设置管理员帐户(或任何已知的帐户)的密码。在 T-SQL 函数中,您可以通过使用 REPLACE 或 QUOTENAME 函数修复此代码。 图 4 显示了使用 REPLACE 函数后正确的代码。
可以看出,REPLACE 会将参数中所有单引号的出现次数都增加一倍。因此,如果攻击者传递相同的参数,该语句会变为:
update Users set password='NewP@ssw0rd' where username='admin''--' and password='dummy'这样就不容易受到通常的 SQL 注入问题的影响了。
通过截断进行修改
如果您仔细留意上面显示的存储过程,您会发现 @command 变量只能存放 100 个字符,但当 25 个字符都为单引号时,这些字符的每个变量经过 REPLACE 函数处理后可返回 50 个字符。如果变量没有足够大的缓冲区,SQL Server 2000 SP4 和 SQL Server 2005 SP1 会自行截断数据。这就为攻击者提供了截断命令字符串的机会。
在此例中,如果有人可以在 username='username' 表达式后截断命令,那么无需知道已知用户的当前密码,就可更改其帐户的密码。
假设攻击者知道 administrator 用户名存在于 Web 应用程序中(这可以是任何用户帐户)。攻击者需要提供长度为 41 个字符的新密码,以使命令的长度足以被适当截断 — 之所以是 41 个字符,是因为在用于命令的 100 个字符中,27 个字符用于更新语句,17 个字符用于 where 子句,13 个字符用于“administrator”,2 个字符用于新密码前后的单引号。
攻击者只能传递 25 个字符作为新密码。但他可以通过传递单引号避开这一限制,因为 REPLACE 函数会使单引号数量增加一倍。因此,通过传递 18 个单引号、1 个大写字母、1 个符号、2 个小写字母和 1 个数字,攻击者就可以截断 where username='administrator' 表达式后面的命令了。如果攻击者将 ''''''''''''''''''!Abb1 传递给 @new 参数,并将 administrator 作为用户名参数,那么 @command 就会变成:
update Users set password= '''''''''''''''''''''''''''''''''''''!Abb1' where username='administrator'
图 5 使用 QUOTENAME 而非 REPLACE。上面的例子和此例的唯一不同在于,在上例中,开发人员为用户名、新密码和旧密码添加了单引号分隔符,而在此例中,由 QUOTENAME 函数添加。由于用户提供的数据没有变化,因此上例中使用的同一攻击字符串仍然可以被攻击者利用。图 6 是在中间层应用程序中编写的 C/C++ 函数的缩写版本,可实现相同功能。它容易受到相同的攻击。
通过截断进行 SQL 注入
图 7 显示了相同代码的另一变体,但可使用单独的变量进行修复。可以看出,此代码将转义后的字符串存放在单独的变量中,而且 @command 有足够的缓冲区来存放整个字符串。@escaped_username、@escaped_oldpw 和 @escaped_newpw 被声明为 varchar(25),但如果 @username、@old 和 @new 中的所有字符是 25 个单引号,则它们需要存放 50 个字符。这就为截断已转义的字符串创造了机会。
攻击者可以传递 123...n' 作为新密码,其中 n 是第 24 个字符,使 @escaped_newpw 也成为 123...n'(REPLACE 函数返回的第二个单引号字符会被截断),使最后的查询如下所示,攻击者可以通过用户名字段注入代码,从而利用此查询:
update users set password='123...n'' where username='<SQL Injection here using Username>这种代码模式更危险,因为这为注入 SQL 代码(而不仅仅是截断现有 SQL)提供了机会。
图 8 提供了使用 QUOTENAME 函数而非 REPLACE 的同一变体的另一个例子。由于 QUOTENAME 函数要添加分隔符,因此负载会有所不同,但仍旧容易受到 SQL 注入攻击。
在此例中,代码将分隔后的字符串存放在单独的变量中,而且 @command 有足够的缓冲区来存放整个命令字符串。正如上例所示,问题在于被引用的变量 @quoted_username、@quoted_oldpw 和 @quoted_newpw。它们都被声明为 varchar(25),但如果 @username、@old 和 @new 中的所有字符是 25 个单引号,则它们需要存放 52 个字符。(QUOTENAME 还将添加开始和结束的分隔符。)这就为攻击者截断已分隔的字符串创造了机会。
攻击者可以传递 123...n(其中 n 是第 24 个字符)作为新密码,使 @escaped_newpw 也成为 '123...n(开始的单引号由 QUOTENAME 添加),使最后的查询如下所示,攻击者可以通过用户名字段注入代码,从而利用此查询:
update users set password='123...n where username=' <SQL Injection here using Username>图 9 是 C/C++ 中此代码的缩写版本,可实现相同功能。它同样容易受到相同攻击。
尽管我在演示中使用的是 T-SQL 代码,但实际上您不需要使用动态 SQL 来构造数据操作语言 (DML) 语句,因此大多数包含 DML 代码的应用程序不易受到这些问题的困扰。
下面,我们来看看另一个根据用户输入构造动态 DDL 语句的例子,如图 10 所示。就像前面的例子一样,以下语句也存在截断问题:
set @escaped_oldpw = quotename(@old, '''') set @escaped_newpw = quotename(@new, '''')攻击者通过传递 @new = '123...'(其中从第 127 个字符(无单引号)开始是 @old = '; SQL Injection'),会使 SQL 语句如下所示:
alter login [loginname] with password = '123... old_password = '; SQL Injection
尽管存储过程更可能出现这些问题,但并非所有存储过程都会导致安全漏洞。接下来介绍哪些存储过程需要仔细审查。
在 SQL Server 中,默认情况下,所有存储过程都在调用方的环境下执行。因此,即使某个过程存在 SQL 注入问题,对该过程具有执行权限的恶意的本地用户也无法提高其权限,并且注入的代码会在其环境下执行。但是如果您有内部维护脚本,作为计算机所有者或某个特定用户可以执行该脚本,那么调用方就可以在不同用户环境下执行代码,并将其权限提升为该用户的权限。
所有截断问题肯定都是 Bug,但它们不一定是安全漏洞。但最好还是修复这些问题,因为您并不知道将来谁会找出这些问题并对其加以利用。
您可以采取其他措施减少您的 SQL 代码中的注入漏洞。首先,在存储过程中避免使用动态 SQL 来构造 DML 语句。如果您无法避免使用动态 SQL,那么可以使用 sp_executesql。第二,正如本文所举的例子中说明的,您需要正确计算缓冲区的长度。最后,在 C/C++ 代码中,检查字符串运算返回值,并查看字符串是否已截断,如果已截断,则相应的结果错误。参见提要栏“漏洞检测方法”,了解您可以采取的措施的摘要。
通过截断检测注入
要利用自动化工具通过截断问题检测 SQL 注入,需要对所有会产生截断可能性的代码模式有非常清楚的了解。您可以针对不同的特定代码模式使用不同的字符串数据。在下述情形中,假定 n 是输入缓冲区的长度。
要检测 QUOTENAME 分隔问题,首先假设使用 QUOTENAME(或 C/C++ 应用程序采用的类似函数)来准备分隔标识符或字符串,并且分隔字符串缓冲区大小小于 2*n + 2。当分隔字符串缓冲区长度等于 n 时,要捕获这些问题,可传递未分隔的长字符串。尾部分隔符将被截断,利用其他某个输入变量,您将获得注入机会。
当分隔缓冲区长度为奇数时,要捕获这些问题,可传递单引号字符(或右方括号或双引号)的长字符串。由于 QUOTENAME 会将所有分隔符出现的次数增加一倍,并添加开始的分隔字符,因此当已转义的字符串缓冲区只能存放奇数个字符时,尾部分隔符会被截断。
当分隔缓冲区长度为偶数时,要捕获这些问题,可传递像 1'、1''、1'''、1'''' 等这样的字符串,每次迭代时使单引号(或右方括号)数量递增。由于 QUOTENAME 会使所有单引号的出现次数增加一倍,因此在返回的字符串中会有偶数个单引号,加上开始的分隔符和 1,最终会有偶数个字符。因此,尾部的分隔符会被截断。
如果使用 REPLACE(或 C/C++ 应用程序采用的类似函数)来准备已转义的字符串,并且当已转义的字符串缓冲区大小小于 2*n 时,您也可以检测出上述问题。当已转义的字符串缓冲区长度等于 n 时,要捕获这些问题,可传递像 1'、12'、123' 和 123...n' 等这样的字符串,每次迭代时使输入字符串的长度递增。在这种情况下,如果您达到合适的长度,那么 REPLACE 函数就会将最后一个单引号字符再增加一个。由于已转义的字符串变量不具备足够的缓冲区空间,因此最后一个单引号会被截断,并在传递时保存起来,从而为打破 SQL 语句提供了机会。
当已转义的缓冲区长度为奇数时,要通过 REPLACE 捕获问题,可传递长度逐渐递增的单引号字符串,如 ''、''' 和 ''''…'(或者只传递单引号字符的长字符串)。在这种情况下,REPLACE 将使所有单引号的出现次数增加一倍。但是由于有的缓冲区长度是奇数,因此最后一个单引号会被截断,这就为打破语句提供了机会。
当已转义缓冲区长度为偶数时,要捕获这些问题,可传递像 1'、1''、1'''、1'''' 等这样的字符串,每次迭代时使单引号(或右方括号)数量递增。返回值在没有 1 开始的情况下将包含偶数个字符,因此整个返回值有奇数个字符。由于缓冲区长度是偶数的,因此尾部的单引号会被截断,从而为打破 SQL 语句提供了机会。
使用代码审查 如果您要执行代码审查,可使用以下几种检测方法来检测 SQL 语句中存在的问题。
检测第一级或第二级 SQL 注入
- 识别用于执行动态 SQL 语句的 API。
- 检查是否对动态 SQL 语句中所使用的数据进行了任何数据验证。
- 如果没有执行过数据验证,则检查该数据是否对分隔字符(字符串使用单引号,SQL 标识符使用右方括号)进行了转义。
通过截断问题检测 SQL 的修改
- 检查要用于存储最终动态 SQL 语句的缓冲区长度。
- 计算在极限输入情况下存放 SQL 语句所需的最大缓冲区,并查看用于存放 SQL 语句的缓冲区是否足够大。
- 特别要注意 QUOTENAME 或 REPLACE 函数的返回值,如果输入数据的长度是 n 个字符,当所有输入字符都为分隔字符时,这些函数返回值的长度将为 2*n + 2 或 2*n。
- 对于 C/C++ 应用程序,应检查像 StringCchPrintf 这样用于准备 SQL 语句的 API 的返回值是否检查过缓冲区不足的错误。
通过截断问题检测 SQL 注入
- 检查用于存放分隔字符串或已转义字符串的缓冲区长度。
- 如果输入字符串的长度为 n,则您需要 2*n + 2 长度的缓冲区来存放 QUOTENAME 函数的返回值,需要 2*n 长度的缓冲区来存放 REPLACE 函数的返回值。
- 对于 C/C++ 应用程序,应检查与 REPLACE 同等函数的返回值是否检查过缓冲区不足的错误。
使用黑盒方法 如果您有自动化工具或智能模糊处理程序,那么可使用以下几种检测方法来检测 SQL 语句中存在的问题。
检测 SQL 注入问题
- 发送单引号作为输入数据,以捕获用户输入数据没有经过净化并被用作动态 SQL 语句中的字符串的情况。
- 使用右方括号(] 字符)作为输入数据,以捕获用户输入没有经过任何输入净化就用在 SQL 标识符中的情况。
检测截断问题
- 发送长字符串,就像您发送字符串检测缓冲区溢出一样。
通过截断问题检测 SQL 的修改
- 发送单引号字符(或右方括号或双引号)的长字符串。这将使 REPLACE 和 QUOTENAME 函数的返回值长度达到最大值,并可能截断用于存放 SQL 语句的命令变量。