SQL注入绕过mysql_real_escape_string()

即使使用mysql_real_escape_string()函数,是否有SQL注入的可能性?

考虑这个示例情况。 SQL是像这样在PHP中构build的:

 $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代码注入?

考虑以下查询:

 $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_real_escape_string()

对于非常OBSCURE边缘案件!

漫长的回答并不容易。 这是基于在这里演示的攻击。

攻击

那么,让我们先来展示攻击

 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行。 我们来分析一下这里发生了什么:

  1. select一个字符集

     mysql_query('SET NAMES gbk'); 

    为了使这种攻击起作用,我们需要服务器在连接上预期的编码'如ASCII码0x27 ,最后一个字节是ASCII码,即0x5c 。 事实certificate,MySQL 5.6默认支持5种这样的编码: big5cp932gb2312gbksjis 。 我们将在这里selectgbk

    现在,在这里注意使用SET NAMES是非常重要的。 这将字符集设置为ON THE SERVER 。 如果我们使用C API函数mysql_set_charset()的调用,那么我们会很好(自2006年以来在MySQL上发布)。 但更多的是为什么在一分钟…

  2. 有效载荷

    我们要用于这个注入的有效负载从字节序列0xbf27 。 在gbk ,这是一个无效的多字节字符; 在latin1 ,它是string¿' 。 请注意,在latin1 gbk0x27本身就是一个文字字符。

    我们select了这个有效载荷,因为如果我们在它上面调用addslashes() ,我们会在'字符之前插入一个ASCII \ ie 0x5c 。 所以,我们最后会得到0xbf5c27 ,它在gbk是一个两字符序列: 0xbf5c后面是0x27 。 或换句话说,一个有效的字符后跟一个非转义的' 。 但是我们不使用addslashes() 。 所以下一步…

  3. mysql_real_escape_string()

    调用mysql_real_escape_string()的C API不同于addslashes() ,因为它知道连接字符集。 所以它可以对服务器期望的字符集进行正确的转义。 然而,到目前为止,客户认为我们仍然在使用latin1来进行连接,因为我们从来没有告诉过它。 我们确实告诉我们使用gbk服务器 ,但客户端仍然认为它是latin1

    因此调用mysql_real_escape_string()插入反斜杠,我们在“转义”内容中有一个自由悬挂'字符! 事实上,如果我们在gbk字符集中查看$var ,我们会看到:

     缞'OR 1 = 1 / * 

    这正是攻击所需要的。

  4. 查询

    这部分只是一个forms,但这里是呈现的查询:

     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将悄无声息地回退到MySQL本身无法准备的模拟语句:那些可以在手册中列出的语句,但是要注意select适当的服务器版本)。

丑陋

我刚开始说如果我们使用mysql_set_charset('gbk')而不是SET NAMES gbk ,我们可以阻止所有这些。 如果您自2006年以来使用MySQL版本,那就是这样。

如果您使用的是较早的MySQL版本,那么mysql_real_escape_string()的错误意味着无效的多字节字符(例如我们的有效内容中的那些字符)被视为单个字节以用于转义目的, 即使客户端已经正确地被通知连接编码等这次攻击仍然会成功。 该错误在MySQL 4.1.20,5.0.22和5.1.11中修复。

但最糟糕的是, PDO在5.3.6之前并没有公开C API for mysql_set_charset() ,所以在以前的版本中,它不能阻止这个攻击的每一个可能的命令! 它现在作为DSN参数公开。

拯救的恩典

正如我们在一开始所说的那样,要使这个攻击行得通,必须使用易受攻击的字符集对数据库连接进行编码。 utf8mb4 不是脆弱的 ,但可以支持每一个 Unicode字符:所以你可以select使用它,但它只有自MySQL 5.5.3以来才可用。 另一种select是utf8 ,它也不易受攻击 ,可以支持整个Unicode Basic Multilingual Plane 。

或者,您可以启用NO_BACKSLASH_ESCAPES SQL模式,该模式会(除其他外)更改mysql_real_escape_string()的操作。 启用该模式后, 0x27将被replace为0x2727而不是0x5c27 ,因此转义过程不能在任何之前不存在的脆弱编码中创build有效字符(即0xbf27仍然是0xbf27等) – 所以服务器仍然会拒绝该string为无效。 但是,请参阅@ eggyal针对使用此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 /*")); 

因为我们已经closures了模拟的准备好的语句。

 $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,全$mysqli->set_charset()等) mysql_set_charset() / $mysqli->set_charset() / PDO的DSN字符集参数( $mysqli->set_charset()

要么

  • 不要使用易受攻击的字符集进行连接编码(只能使用utf8 / latin1 / ascii / etc)

你100%安全。

否则, 即使使用mysql_real_escape_string() ,您也是脆弱的。

TL; DR

mysql_real_escape_string()将不会提供任何保护 (并且还可能包含数据):

  • MySQL的NO_BACKSLASH_ESCAPES SQL模式已启用( 可能是这样,除非您在每次连接时 明确select另一个SQL模式); 和

  • 你的SQLstring文字是用双引号"字符引用的。

这是作为bug#72458提交的,并已在MySQL v5.7.6中修复(参见下面的“ 保存宽限 ”一节)。

这是另一个,(也许更less?)掩盖EDGE CASE!

对@ ircmaxell的出色答案 (实际上,这应该是奉承而不是抄袭),我会采用他的格式:

攻击

从演示开始

 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表中的所有logging。 解剖:

  1. select一个SQL模式

     mysql_query('SET SQL_MODE="NO_BACKSLASH_ESCAPES"'); 

    如string文字所logging:

    在string中包含引号字符有几种方法:

    • 用“ ' ”引用的string中的“ ' ”可以写成“ '' ”。

    • 引用“ " “的string中的” " ”可以写成“ "" ”。

    • 在转义字符(“ \ ”)前面加上引号字符。

    • 用“ "引起来的string中的“ " “不需要特殊处理,不需要加倍或转义,同样的,” " ”string中的“ ' “也不需要特殊处理。

    如果服务器的SQL模式包含NO_BACKSLASH_ESCAPES ,那么第三个选项(这是mysql_real_escape_string()采用的常用方法mysql_real_escape_string()不可用:必须使用前两个选项之一。 请注意,第四个项目符号的效果是,必须知道将用来引用文字的字符,以避免传送数据。

  2. 有效载荷

     " OR 1=1 -- 

    有效负载完全用"字符"启动这个注入,没有特别的编码,没有特殊的字符,没有奇怪的字节。

  3. 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() 。 如上所述,这样的函数需要知道哪个字符将被用来引用该字面值以重复该字面值,而不会导致其他引用字符被逐字地重复。

    然而,这个函数任意地假设string将被引用使用单引号字符。 参见charset.c

     /* Escape apostrophes by doubling them up // [ deletia 839-845 ] DESCRIPTION This escapes the contents of a string by doubling up any apostrophes that it contains. This is used when the NO_BACKSLASH_ESCAPES SQL_MODE is in effect on the server. // [ 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) { overflow= TRUE; break; } *to++= '\''; *to++= '\''; } 

    所以, 不pipe引用字面值的实际字符如何,它都会保留双引号字符(并且使所有单引号字符加倍)!在我们的例子中, $var与提供给mysql_real_escape_string()的参数保持完全相同mysql_real_escape_string() – 就好像没有逃脱一样。

  4. 查询

     mysql_query('SELECT * FROM test WHERE name = "'.$var.'" LIMIT 1'); 

    某种forms,呈现的查询是:

     SELECT * FROM test WHERE name = "" OR 1=1 -- " LIMIT 1 

正如我学过的朋友所说的那样:恭喜,您只是使用mysql_real_escape_string()成功攻击了一个程序。

坏的

mysql_set_charset()不能帮助,因为这与字符集无关; 也不能mysqli::real_escape_string() ,因为这只是一个不同的包装这个相同的function。

这个问题(如果还不是很明显的话)是,对mysql_real_escape_string()的调用无法知道文字将被引用的字符,因为这留给开发人员稍后决定。 所以,在NO_BACKSLASH_ESCAPES模式下,从字面上看, 没有办法 ,这个函数可以安全地转义每个input以便使用任意引用(至less,不能不加倍的字符,因此不需要加倍,从而消除数据)。

丑陋

它变得更糟。 NO_BACKSLASH_ESCAPES可能并不罕见,因为它需要与标准SQL兼容(例如参见SQL-92规范的第5.3节,即<quote symbol> ::= <quote><quote>生产和反斜杠没有任何特殊的含义)。 此外,它的使用被明确推荐为 ircmaxell的文章描述的(长期以来固定的) 错误 的解决方法 。 谁知道,一些DBA甚至可能会将其configuration为默认开启,作为劝阻使用不正确的转义方法(如addslashes()

而且, 新连接的SQL模式由服务器根据其configuration( SUPER用户可随时更改)来设置; 因此,要确定服务器的行为,必须在连接之后始终明确指定所需的模式。

拯救的恩典

只要你总是明确地设置SQL模式不包括NO_BACKSLASH_ESCAPES ,或者用单引号字符引用MySQLstring,那么这个错误就不能使用丑陋的头:分别使用escape_quotes_for_mysql()或者不使用escape_quotes_for_mysql()字符需要重复才会正确。

出于这个原因,我build议任何使用NO_BACKSLASH_ESCAPES也启用ANSI_QUOTES模式,因为它会强制习惯性地使用单引号string文字。 请注意,这并不能防止在双引号文字恰好被使用的情况下进行SQL注入 – 它只是减less了这种情况发生的可能性(因为正常的非恶意查询会失败)。

在PDO中,它的等价函数PDO::quote()及其准备好的语句模拟器调用mysql_handle_quoter() ,它确实如此:它确保转义的文字被引用单引号,所以你可以确定PDO是总是免受这个bug的影响。

从MySQL v5.7.6开始,这个bug已经修复了。 查看更改日志 :

function添加或更改

  • 不兼容的更改:新的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() ,如果启用CR_INSECURE_API_ERR ,现在会失败并产生CR_INSECURE_API_ERR错误。

    参考文献:另见Bug#19211994。

安全示例

结合ircmaxell解释的错误,下面的例子是完全安全的(假设一个使用比4.1.20,5.0.22,5.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'); 

…因为我们明确select了一个不包含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"); 

…因为我们用单引号引用我们的string文字。

 $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的bug,你必须使用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总结中采用其中一种解决scheme外,还应至less使用以下一种方法:

    • PDO;
    • 单引号string文字; 要么
    • 一个明确设置的SQL模式,不包括NO_BACKSLASH_ESCAPES

那么你应该是完全安全的(漏掉string范围之外的漏洞)。

那么,除了%通配符以外,没有什么可以通过的。 如果你使用LIKE语句,攻击者可能会把%作为login名,如果你不过滤掉,那么这可能会很危险,而且只能暴露你的任何用户的密码。 人们经常build议使用预先准备好的语句来使其100%安全,因为数据不能以这种方式干涉查询本身。 但是对于这样简单的查询,执行类似$login = preg_replace('/[^a-zA-Z0-9_]/', '', $login);可能会更有效率$login = preg_replace('/[^a-zA-Z0-9_]/', '', $login);