即使使用 mysql_real_escape_string()
函数,是否也存在 SQL 注入的可能性?
考虑这个示例情况。 SQL 在 PHP 中构造如下:
$login = mysql_real_escape_string(GetFromPost('login'));
$password = mysql_real_escape_string(GetFromPost('password'));
$sql = "SELECT * FROM table WHERE login='$login' AND password='$password'";
我听到很多人对我说,即使使用了 mysql_real_escape_string()
函数,这样的代码仍然很危险并且可能被破解。但我想不出任何可能的利用?
像这样的经典注射:
aaa' OR 1=1 --
不工作。
你知道任何可能通过上面的 PHP 代码进行的注入吗?
mysql_*
functions in new code。它们不再被维护,deprecation process 已经开始。看到 red box 了吗?请改为了解 prepared statements,然后使用 PDO 或 MySQLi - this article 将帮助您决定使用哪个。如果您选择 PDO,here is a good tutorial。
mysql_*
函数已经产生 E_DEPRECATED
警告。 ext/mysql
扩展已超过 10 年未维护。你真的这么迷信吗?
简短的回答是是的,是的,有办法绕过mysql_real_escape_string()
。 #对于非常晦涩的边缘案例!!!
长答案并不容易。它基于攻击 demonstrated here。
攻击
所以,让我们从展示攻击开始......
mysql_query('SET NAMES gbk');
$var = mysql_real_escape_string("\xbf\x27 OR 1=1 /*");
mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");
在某些情况下,这将返回超过 1 行。让我们剖析一下这里发生了什么:
选择一个字符集 mysql_query('SET NAMES gbk');为了使这种攻击起作用,我们需要服务器在连接上期望的编码既能将 ' 编码为 ASCII 即 0x27,又要有某个字符的最后一个字节是 ASCII \ 即 0x5c。事实证明,MySQL 5.6 默认支持 5 种这样的编码:big5、cp932、gb2312、gbk 和 sjis。我们将在这里选择 gbk。现在,注意此处使用 SET NAMES 非常重要。这会在服务器上设置字符集。如果我们使用对 C API 函数 mysql_set_charset() 的调用,我们会很好(在 2006 年以来的 MySQL 版本上)。但是稍后会详细解释为什么... 有效负载 我们将用于此注入的有效负载以字节序列 0xbf27 开头。在 gbk 中,这是一个无效的多字节字符;在 latin1 中,它是字符串 ¿'。请注意,在 latin1 和 gbk 中,0x27 本身就是一个文字 ' 字符。我们选择这个payload是因为,如果我们在它上面调用addslashes(),我们会在'字符之前插入一个ASCII\即0x5c。所以我们最终会得到 0xbf5c27,它在 gbk 中是两个字符序列:0xbf5c 后跟 0x27。或者换句话说,一个有效字符后跟一个未转义的 '。但是我们没有使用addlashes()。继续下一步…… mysql_real_escape_string() 对 mysql_real_escape_string() 的 C API 调用与 addlashes() 的不同之处在于它知道连接字符集。因此它可以对服务器期望的字符集进行正确的转义。然而,到目前为止,客户端认为我们仍在使用 latin1 进行连接,因为我们从未告诉过它。我们确实告诉服务器我们正在使用 gbk,但客户端仍然认为它是 latin1。因此,对 mysql_real_escape_string() 的调用插入了反斜杠,我们的“转义”内容中有一个自由悬挂的 ' 字符!事实上,如果我们查看 gbk 字符集中的 $var,我们会看到: 缞' OR 1=1 /* 这正是攻击所需要的。查询 这部分只是形式,但这里是呈现的查询: SELECT * FROM test WHERE name = '缞' OR 1=1 /*' LIMIT 1
恭喜,您刚刚成功地使用 mysql_real_escape_string()
攻击了一个程序...
坏的
它变得更糟。 PDO
默认模拟 MySQL 准备好的语句。这意味着在客户端,它基本上通过 mysql_real_escape_string()
(在 C 库中)执行 sprintf,这意味着以下将导致成功注入:
$pdo->query('SET NAMES gbk');
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(array("\xbf\x27 OR 1=1 /*"));
现在,值得注意的是,您可以通过禁用模拟的准备好的语句来防止这种情况:
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
这通常会产生一个真正的准备好的语句(即数据在一个单独的数据包中与查询一起发送)。但是,请注意 PDO 将默默地fallback 模拟 MySQL 无法本地准备的语句:它可以在手册中是 listed,但要注意选择适当的服务器版本)。
丑陋的
我在一开始就说过,如果我们使用 mysql_set_charset('gbk')
而不是 SET NAMES gbk
,我们可以阻止这一切。如果您使用的是自 2006 年以来的 MySQL 版本,情况就是如此。
如果您使用的是较早的 MySQL 版本,那么 mysql_real_escape_string()
中的 bug 意味着无效的多字节字符(例如我们的有效负载中的那些字符)被视为单字节以进行转义即使客户端已被正确通知连接编码,所以这个攻击仍然会成功。该错误已在 MySQL 4.1.20、5.0.22 和 5.1.11 中修复。
但最糟糕的是,PDO
直到 5.3.6 才公开 mysql_set_charset()
的 C API,因此在之前的版本中,它无法针对所有可能的命令阻止这种攻击!它现在显示为 DSN parameter。
拯救恩典
正如我们一开始所说,要使这种攻击起作用,必须使用易受攻击的字符集对数据库连接进行编码。 utf8mb4
不易受攻击,但可以支持每个 Unicode 字符:因此您可以选择使用它来代替 - 但它仅在 MySQL 5.5.3 之后才可用。另一种选择是 utf8
,它也不易受攻击,并且可以支持整个 Unicode Basic Multilingual Plane。
或者,您可以启用 NO_BACKSLASH_ESCAPES
SQL 模式,该模式(除其他外)会改变 mysql_real_escape_string()
的操作。启用此模式后,0x27
将替换为 0x2727
而不是 0x5c27
,因此转义过程无法在以前不存在的任何易受攻击的编码中创建有效字符(即0xbf27
仍然是 0xbf27
等)——因此服务器仍将拒绝该字符串为无效。但是,请参阅 @eggyal's answer,了解使用此 SQL 模式可能产生的不同漏洞。
安全示例
以下示例是安全的:
mysql_query('SET NAMES utf8');
$var = mysql_real_escape_string("\xbf\x27 OR 1=1 /*");
mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");
因为服务器期望 utf8
...
mysql_set_charset('gbk');
$var = mysql_real_escape_string("\xbf\x27 OR 1=1 /*");
mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");
因为我们已经正确设置了字符集,所以客户端和服务器匹配。
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$pdo->query('SET NAMES gbk');
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(array("\xbf\x27 OR 1=1 /*"));
因为我们已经关闭了模拟的准备好的语句。
$pdo = new PDO('mysql:host=localhost;dbname=testdb;charset=gbk', $user, $password);
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(array("\xbf\x27 OR 1=1 /*"));
因为我们已经正确设置了字符集。
$mysqli->query('SET NAMES gbk');
$stmt = $mysqli->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$param = "\xbf\x27 OR 1=1 /*";
$stmt->bind_param('s', $param);
$stmt->execute();
因为 MySQLi 一直在做真正的准备好的语句。
包起来
如果你:
使用 MySQL 的现代版本(5.1 后期,所有 5.5、5.6 等)和 mysql_set_charset() / $mysqli->set_charset() / PDO 的 DSN 字符集参数(在 PHP ≥ 5.3.6 中)
或者
不要使用易受攻击的字符集进行连接编码(您只使用 utf8 / latin1 / ascii / 等)
你是 100% 安全的。
否则,即使您使用的是 mysql_real_escape_string()
,您也很容易受到攻击...
考虑以下查询:
$iId = mysql_real_escape_string("1 OR 1=1");
$sSql = "SELECT * FROM table WHERE id = $iId";
mysql_real_escape_string()
不会保护您免受这种情况的影响。 您在查询中的变量周围使用单引号 (' '
) 可以保护您免受这种情况的影响。 以下也是一种选择:
$iId = (int)"1 OR 1=1";
$sSql = "SELECT * FROM table WHERE id = $iId";
mysql_query()
不会执行多个语句,不是吗?
DROP TABLE
,但实际上攻击者更有可能SELECT passwd FROM users
。在后一种情况下,通常使用 UNION
子句执行第二个查询。
(int)mysql_real_escape_string
- 这毫无意义。它与 (int)
完全没有区别。他们将为每个输入产生相同的结果
mysql_real_escape_string
,而不是 mysql_real_escape_integer
。这并不意味着与整数字段一起使用。
TL;DR mysql_real_escape_string() 在以下情况下将不提供任何保护(并且可能进一步破坏您的数据): MySQL 的 NO_BACKSLASH_ESCAPES SQL 模式已启用(它可能是,除非您在每次连接时明确选择另一个 SQL 模式);并且您的 SQL 字符串文字使用双引号 " 字符引用。这是作为错误 #72458 提交的,并已在 MySQL v5.7.6 中修复(请参阅下面标题为“The Saving Grace”的部分)。
这是另一个(可能更少?)晦涩的边缘案例!
为了向 @ircmaxell's excellent answer 致敬(真的,这应该是奉承而不是抄袭!),我将采用他的格式:
攻击
从演示开始...
mysql_query('SET SQL_MODE="NO_BACKSLASH_ESCAPES"'); // could already be set
$var = mysql_real_escape_string('" OR 1=1 -- ');
mysql_query('SELECT * FROM test WHERE name = "'.$var.'" LIMIT 1');
这将返回 test
表中的所有记录。解剖:
选择 SQL 模式 mysql_query('SET SQL_MODE="NO_BACKSLASH_ESCAPES"');如字符串文字中所述:有几种方法可以在字符串中包含引号字符:用“'”引用的字符串中的“'”可以写为“''”。用“"”引用的字符串中的“"”可以写成“""”。在引号字符前加上转义字符 (“\”)。用“"”引用的字符串中的“'”不需要特殊处理,也不需要加倍或转义。同样,用“'”引用的字符串中的“"”不需要特殊处理。如果服务器的 SQL 模式包含 NO_BACKSLASH_ESCAPES,则这些选项中的第三个——这是 mysql_real_escape_string() 采用的常用方法——不可用:必须使用前两个选项之一。请注意,第四个项目符号的效果是必须知道将用于引用文字的字符,以避免篡改数据。有效负载 " OR 1=1 -- 有效负载完全使用 " 字符启动此注入。没有特定的编码。没有特殊字符。没有奇怪的字节。 mysql_real_escape_string() $var = mysql_real_escape_string('" OR 1=1 -- '); 幸运的是,mysql_real_escape_string() 确实会检查 SQL 模式并相应地调整其行为。参见 libmysql.c: ulong STDCALL mysql_real_escape_string(MYSQL *mysql, char * to,const char *from, ulong length) { if (mysql->server_status & SERVER_STATUS_NO_BACKSLASH_ESCAPES) return escape_quotes_for_mysql(mysql->charset, to, 0, from, length); return escape_string_for_mysql(mysql->charset, to, 0, from , length); } 因此,如果使用 NO_BACKSLASH_ESCAPES SQL 模式,则调用不同的底层函数 escape_quotes_for_mysql()。如上所述,这样的函数需要知道哪个字符将用于引用文字以便重复它不会导致其他引号字符从字面上重复。但是,此函数任意假定字符串将使用单引号 ' 字符引用。参见 charset.c: /* 通过将撇号加倍来转义撇号 // [ deletia 839- 845 ] 描述 这通过将字符串包含的任何撇号加倍来转义字符串的内容。当 NO_BACKSLASH_ESCAPES SQL_MODE 在服务器上生效时使用此选项。 // [ deletia 852-858 ] */ size_t escape_quotes_for_mysql(CHARSET_INFO *charset_info, char *to, size_t to_length, const char *from, size_t length) { // [ deletia 865-892 ] if (*from == '\' ') { if (to + 2 > to_end) { 溢出= TRUE;休息; } *to++= '\''; *to++= '\'';因此,无论用于引用文字的实际字符如何,它都不会触及双引号 " 字符(并将所有单引号 ' 字符加倍)!在我们的例子中,$var 与提供给mysql_real_escape_string()——好像根本没有转义。查询 mysql_query('SELECT * FROM test WHERE name = "'.$var.'" LIMIT 1'); 有点形式,呈现的查询是: SELECT * FROM test WHERE name = "" OR 1=1 --" LIMIT 1
正如我博学的朋友所说:恭喜,您刚刚成功地使用 mysql_real_escape_string()
攻击了一个程序...
坏的
mysql_set_charset()
无能为力,因为这与字符集无关; mysqli::real_escape_string()
也不能,因为这只是同一函数的不同包装。
问题(如果不是很明显的话)是对 mysql_real_escape_string()
无法知道使用哪个字符引用文字的调用,因为这留给开发人员稍后决定。因此,在 NO_BACKSLASH_ESCAPES
模式下,实际上没有办法,此函数可以安全地转义每个输入以用于任意引用(至少,在不需要加倍的字符加倍的情况下,这会影响你的数据)。
丑陋的
它变得更糟。 NO_BACKSLASH_ESCAPES
在野外可能并不罕见,因为它必须用于与标准 SQL 兼容(例如,参见 SQL-92 specification 的第 5.3 节,即 <quote symbol> ::= <quote><quote>
语法产生式并且缺乏赋予反斜杠)。此外,对于 ircmaxell 的帖子所描述的(早已修复的)bug,它的使用是明确的 recommended as a workaround。谁知道呢,一些 DBA 甚至可能将其配置为默认开启,以阻止使用不正确的转义方法(如 addslashes()
)。
此外,SQL mode of a new connection 由服务器根据其配置设置(SUPER
用户可以随时更改);因此,为了确定服务器的行为,您必须始终在连接后明确指定所需的模式。
拯救恩典
只要您始终明确将 SQL 模式设置为不包含 NO_BACKSLASH_ESCAPES
,或者使用单引号字符引用 MySQL 字符串文字,这个 bug 就无法抬头:分别 escape_quotes_for_mysql()
不会被使用,或者它关于哪些引号字符需要重复的假设将是正确的。
出于这个原因,我建议使用 NO_BACKSLASH_ESCAPES
的任何人也启用 ANSI_QUOTES
模式,因为它会强制习惯使用单引号字符串文字。请注意,如果碰巧使用了双引号文字,这不会阻止 SQL 注入——它只会降低发生这种情况的可能性(因为正常的、非恶意的查询会失败)。
在 PDO 中,它的等效函数 PDO::quote()
和它的预处理语句仿真器都调用 mysql_handle_quoter()
— 这正是这样做的:它确保转义的文字用单引号引起来,因此您可以确定 PDO 始终免疫这个错误。
自 MySQL v5.7.6 起,此错误已得到修复。请参阅change log:
添加或更改的功能 不兼容的更改:已实现一个新的 C API 函数 mysql_real_escape_string_quote() 以替代 mysql_real_escape_string(),因为启用 NO_BACKSLASH_ESCAPES SQL 模式时后一个函数可能无法正确编码字符。在这种情况下,mysql_real_escape_string() 不能转义引号字符,除非将它们加倍,并且要正确执行此操作,它必须知道有关引用上下文的更多信息而不是可用的信息。 mysql_real_escape_string_quote() 需要一个额外的参数来指定引用上下文。有关使用详情,请参阅 mysql_real_escape_string_quote()。注意应修改应用程序以使用 mysql_real_escape_string_quote(),而不是 mysql_real_escape_string(),如果启用 NO_BACKSLASH_ESCAPES,现在会失败并产生 CR_INSECURE_API_ERR 错误。参考:另见错误 #19211994。
安全示例
结合ircmaxell解释的bug,下面的例子是完全安全的(假设一个是使用晚于4.1.20、5.0.22、5.1.11的MySQL;或者一个没有使用GBK/Big5连接编码) :
mysql_set_charset($charset);
mysql_query("SET SQL_MODE=''");
$var = mysql_real_escape_string('" OR 1=1 /*');
mysql_query('SELECT * FROM test WHERE name = "'.$var.'" LIMIT 1');
...因为我们明确选择了不包括 NO_BACKSLASH_ESCAPES
的 SQL 模式。
mysql_set_charset($charset);
$var = mysql_real_escape_string("' OR 1=1 /*");
mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");
...因为我们用单引号引用我们的字符串文字。
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(["' OR 1=1 /*"]);
...因为 PDO 准备语句不受此漏洞的影响(ircmaxell 也是如此,前提是您使用的是 PHP≥5.3.6 并且 DSN 中的字符集已正确设置;或者准备语句模拟已被禁用) .
$var = $pdo->quote("' OR 1=1 /*");
$stmt = $pdo->query("SELECT * FROM test WHERE name = $var LIMIT 1");
...因为 PDO 的 quote()
函数不仅转义了文字,而且还引用了它(在单引号 '
字符中);请注意,为了避免这种情况下出现 ircmaxell 的错误,您必须使用 PHP≥5.3.6 并且已正确设置 DSN 中的字符集。
$stmt = $mysqli->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$param = "' OR 1=1 /*";
$stmt->bind_param('s', $param);
$stmt->execute();
...因为 MySQLi 准备好的语句是安全的。
包起来
因此,如果您:
使用本机准备好的语句
或者
使用 MySQL v5.7.6 或更高版本
或者
除了采用 ircmaxell 总结中的解决方案之一,至少使用以下之一: PDO;单引号字符串文字;或不包含 NO_BACKSLASH_ESCAPES 的显式设置的 SQL 模式
PDO;
单引号字符串文字;或者
不包含 NO_BACKSLASH_ESCAPES 的显式设置的 SQL 模式
...那么您应该是完全安全的(字符串转义范围之外的漏洞)。
"
用于字符串。 SQL 说这是针对标识符的。但是,嗯……这只是 MySQL 的另一个例子,它说“螺丝标准,我会做任何我想做的事”。 (幸运的是,您可以在模式中包含 ANSI_QUOTES
来修复引用错误。但是,公开无视标准是一个更大的问题,可能需要更严厉的措施。)
quote()
函数避免 这个特定的错误,但准备好的语句是一种更安全、更合适的方式一般避免注射。当然,如果您直接将未转义的变量连接到您的 SQL 中,那么无论您之后使用什么方法,您都肯定容易受到注入的影响。
好吧,除了 %
通配符之外,没有什么可以通过它。如果您使用 LIKE
语句可能会很危险,因为如果您不将其过滤掉,攻击者可能只会将 %
作为登录名,并且只需要暴力破解您的任何用户的密码。人们经常建议使用准备好的语句来使其 100% 安全,因为数据不会以这种方式干扰查询本身。但是对于这样简单的查询,执行 $login = preg_replace('/[^a-zA-Z0-9_]/', '', $login);
之类的操作可能会更有效
more efficient
? (准备好的语句总是有效的,库可以在受到攻击的情况下快速纠正,不会暴露人为错误[例如错误键入完整的替换字符串],并且如果重复使用语句,则具有显着的性能优势。)
addslashes
。我基于这个漏洞。自己试试。去获取 MySQL 5.0,并运行这个漏洞并亲自看看。至于如何将其放入 PUT/GET/POST,这很简单。输入数据只是字节流。char(0xBF)
只是一种生成字节的可读方式。我已经在多个会议前现场演示了这个漏洞。相信我......但如果你不这样做,请自己尝试。有用...?var=%BF%27+OR+1=1+%2F%2A
中传递这样的时髦,在 URL 中,$var = $_GET['var'];
在代码中,并且 Bob 是你的叔叔。